From e8f884ba3597105105985f4f2673d31c071585bc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 11:50:33 +0800 Subject: [PATCH 01/67] fix(web): make the favicon follow the color scheme favicon.svg embeds @media (prefers-color-scheme: dark) so the DeepSeek mark turns white under a dark browser scheme; index.html and the install manifest gain a 32x32 PNG fallback (brand blue) for Safari, which cannot render SVG favicons. pwa-manifest.e2e.ts pins the PNG link, both manifest icons, and the dark media query. --- ...2026-08-10-web-favicon-dark-mode.i18n.yaml | 6 ++++ .../2026-08-10-web-favicon-dark-mode.md | 25 ++++++++++++++++ .../2026-08-10-web-favicon-dark-mode.zh.md | 25 ++++++++++++++++ .../2026-08-06-web-install-manifest.i18n.yaml | 4 +-- .../2026-08-06-web-install-manifest.md | 6 ++-- .../2026-08-06-web-install-manifest.zh.md | 6 ++-- apps/web/index.html | 1 + apps/web/public/favicon-32x32.png | Bin 0 -> 1193 bytes apps/web/public/favicon.svg | 7 ++++- apps/web/public/manifest.webmanifest | 6 ++++ apps/web/tests/pwa-manifest.e2e.ts | 27 ++++++++++++++---- 11 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md create mode 100644 apps/web/public/favicon-32x32.png diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml new file mode 100644 index 0000000000..2af9edd28a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.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-08-10-web-favicon-dark-mode.md +2026-08-10-web-favicon-dark-mode.md: 45a23ea31d369a7806733edac0c0aff32784a4c9 +2026-08-10-web-favicon-dark-mode.zh.md: 161e12e9d2749fb7dd0b65382be03cf38dd199d0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md new file mode 100644 index 0000000000..45a23ea31d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md @@ -0,0 +1,25 @@ +# Agent Note: Web favicon follows the color scheme + +Status: implemented + +English | [中文](2026-08-10-web-favicon-dark-mode.zh.md) + +## Problem + +`apps/web/public/favicon.svg` paints the DeepSeek mark solid black (`fill="#000"`), and `index.html` declares only that single SVG icon. Under an OS or browser dark color scheme the tab strip is dark too, so the black mark is effectively invisible. Safari additionally ignores SVG favicons entirely, so its users get no tab icon in any scheme. + +## Decision + +The favicon stays one file and adapts through the browser's own color-scheme signal: `favicon.svg` embeds `@media (prefers-color-scheme: dark) { path { fill: #fff } }`, switching the mark to white under a dark scheme while the light scheme keeps black. `index.html` and `manifest.webmanifest` also declare a 32×32 PNG fallback (`favicon-32x32.png`, DeepSeek brand blue `#4D6BFE`) that Safari renders and that stays visible on both light and dark tab strips, extending the [web-install-manifest decision](../feature/2026-08-06-web-install-manifest.md). + +The theme signal is the OS/browser scheme, not the GUI's in-app `dsh.theme` toggle: the favicon lives in browser chrome, whose background follows the browser scheme, so `prefers-color-scheme` is the correct semantic and needs no JavaScript. Known browser quirks — Chromium may not repaint the tab icon until reload after a scheme switch, and Safari ignores the SVG variant — are accepted and the PNG fallback covers the Safari case. + +## Alternatives considered + +- **A second `` pointing at a separate dark SVG.** Rejected: the same scheme semantics with two files to keep in sync, and no benefit over the in-file media query. +- **A theme-presenter that swaps the icon href on `theme/change`.** Rejected: it would follow the in-app toggle rather than the browser scheme that actually colors the tab strip, and it adds client code and a presenter for a chrome asset. +- **No PNG fallback.** Rejected: Safari never renders SVG favicons, so the fallback is the only way it gets a tab icon at all. + +## Consequences + +Light scheme still shows the black mark, dark scheme shows white, and Safari shows the blue PNG in both. `apps/web/tests/pwa-manifest.e2e.ts` now pins the PNG link, both manifest icons, and the dark media query inside the shipped SVG. The Chromium repaint quirk remains a browser behavior the app cannot fix. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md new file mode 100644 index 0000000000..161e12e9d2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 网页图标随配色方案切换 + +Status: implemented + +[English](2026-08-10-web-favicon-dark-mode.md) | 中文 + +## 问题 + +`apps/web/public/favicon.svg` 把 DeepSeek 图标绘制为纯黑色(`fill="#000"`),而 `index.html` 只声明了这一个 SVG 图标。当操作系统或浏览器处于暗色配色方案时,标签栏同样是深色,黑色图标实际上不可见。此外 Safari 完全不渲染 SVG favicon,因此无论何种配色方案,Safari 用户都看不到标签页图标。 + +## 决策 + +favicon 保持单一文件,并通过浏览器自身的配色方案信号自适应:`favicon.svg` 内嵌 `@media (prefers-color-scheme: dark) { path { fill: #fff } }`,在暗色方案下把图标切换为白色,浅色方案保持黑色。`index.html` 与 `manifest.webmanifest` 同时声明 32×32 PNG 兜底(`favicon-32x32.png`,DeepSeek 品牌蓝 `#4D6BFE`),Safari 会渲染该 PNG,且它在浅色与深色标签栏上都清晰可见;这是对 [Web 安装 manifest 决策](../feature/2026-08-06-web-install-manifest.md) 的扩展。 + +主题信号取操作系统/浏览器方案,而不是 GUI 应用内 `dsh.theme` 开关:favicon 位于浏览器 chrome 中,其背景跟随浏览器方案,因此 `prefers-color-scheme` 是正确语义,无需任何 JavaScript。已知的浏览器怪癖——Chromium 在切换方案后可能要到刷新页面才重绘标签图标,Safari 忽略 SVG 变体——均被接受,Safari 场景由 PNG 兜底覆盖。 + +## 曾考虑的替代方案 + +- **新增指向独立暗色 SVG 的第二个 ``。** 不予采纳:语义相同却要多维护一个文件,相比文件内媒体查询没有任何收益。 +- **由主题 presenter 在 `theme/change` 时替换图标 href。** 不予采纳:它会跟随应用内开关,而不是真正决定标签栏颜色的浏览器方案,并且为一个 chrome 资源引入客户端代码和 presenter。 +- **不提供 PNG 兜底。** 不予采纳:Safari 从不渲染 SVG favicon,兜底是它获得标签图标的唯一途径。 + +## 后果 + +浅色方案仍显示黑色图标,暗色方案显示白色,Safari 两种方案都显示蓝色 PNG。`apps/web/tests/pwa-manifest.e2e.ts` 现在固定断言 PNG 链接、manifest 中的两个图标以及交付 SVG 内部的暗色媒体查询。Chromium 的重绘怪癖仍是浏览器行为,应用无法修复。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml index 000c72f6ed..60e342fb9c 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.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-web-install-manifest.md -2026-08-06-web-install-manifest.md: d400c6e586f4b735fa8e3dcc4899c97e45ac89c1 -2026-08-06-web-install-manifest.zh.md: d32ccf25c7b0e13a4094af4364a97385032462b7 +2026-08-06-web-install-manifest.md: 1b3b852fe0d9fbe5643f5ec5d84a8a120b730e34 +2026-08-06-web-install-manifest.zh.md: 9d16b6556888eaebb0e4d501774e2ee8343b25ed diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md index d400c6e586..1b3b852fe0 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md @@ -10,7 +10,7 @@ The Web build has a document title and favicon but no manifest from which a brow ## Decision -The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entry reuses `/favicon.svg` as an SVG of size `any` and purpose `any`. +The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entries reuse `/favicon.svg` as an SVG of size `any` and purpose `any` and add a 32×32 PNG fallback (`/favicon-32x32.png`) for browsers that cannot render SVG favicons, per the [web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md). This follows code-server's fullscreen choice without copying its `window-controls-overlay` display override. DSH has no custom title bar or layout around native window controls, so such an override would supersede fullscreen without owning the required safe layout. @@ -30,10 +30,10 @@ The built-Web test parses the emitted manifest and pins the complete metadata ob **Choose one static background and theme color.** Rejected because the app resolves light and dark palettes at runtime, so either fixed value is knowingly wrong for one supported state. -**Ship raster and maskable icon variants immediately.** Rejected until a supported installation target demonstrates a requirement the existing scalable favicon cannot meet. New variants remain an additive manifest change rather than a prerequisite for exposing the current identity. +**Ship raster and maskable icon variants immediately.** The raster half shipped once Safari demonstrated a requirement the scalable favicon cannot meet: it cannot render SVG favicons, so the manifest now carries a 32×32 PNG alongside the SVG ([web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md)). Maskable variants remain rejected until a supported installation target demonstrates a requirement. **Assert only root and display fields in the built artifact.** Rejected because dropping or changing the product name, compact name, or icon is also a shipped install regression. The test intentionally requires an explicit edit whenever any manifest metadata changes. ## Consequences -Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. Browser-specific icon requirements may add variants later, and every intentional metadata change updates the exact built-artifact contract. +Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. Browser-specific icon requirements added a 32×32 PNG fallback once Safari could not render SVG favicons ([web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md)), and every intentional metadata change updates the exact built-artifact contract. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md index d32ccf25c7..9d16b65568 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md @@ -10,7 +10,7 @@ Web 构建产物已有文档标题和 favicon,却没有可供浏览器发现 ## 决策 -Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG。 +Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG,并额外提供 32×32 的 PNG 兜底(`/favicon-32x32.png`),供无法渲染 SVG favicon 的浏览器使用,详见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md)。 这一选择沿用了 code-server 的全屏方案,但没有照搬其 `window-controls-overlay` 显示覆盖项。DSH 没有自定义标题栏,也没有围绕原生窗口控件安排布局,因此使用这类覆盖项会在未落实所需安全布局的情况下取代全屏模式。 @@ -30,10 +30,10 @@ Web 构建产物测试解析输出的 manifest,并固定完整的元数据对 **选择一组静态背景色和主题色。** 不予采纳,因为应用会在运行时解析浅色和深色调色板,因此选择任一固定值,都是明知它与其中一种受支持状态不符。 -**立即交付光栅和可遮罩图标变体。** 在某个受支持的安装目标证明现有可缩放 favicon 无法满足其要求之前,不予采纳。新变体只是对 manifest 的增量扩展,并非公开当前身份的前提。 +**立即交付光栅和可遮罩图标变体。** 光栅部分已交付:Safari 证明了现有可缩放 favicon 无法满足其要求——它无法渲染 SVG favicon,因此 manifest 现在在 SVG 之外同时携带 32×32 PNG(见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md))。可遮罩变体仍保持不予采纳,直到有受支持的安装目标证明其必要性。 **只断言构建产物中的根路径字段和显示字段。** 不予采纳,因为产品名称、简称或图标被删除或更改,同样属于已交付安装体验的回归。任何 manifest 元数据发生变化时,测试都有意要求显式改动。 ## 后果 -支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。日后可能因浏览器特有的图标要求而新增变体;每一项有意的元数据变更都会同步更新精确的构建产物约定。 +支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。浏览器特有的图标要求已在 Safari 无法渲染 SVG favicon 时新增了 32×32 PNG 兜底(见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md));每一项有意的元数据变更都会同步更新精确的构建产物约定。 diff --git a/apps/web/index.html b/apps/web/index.html index a14de72d40..ae09cf8269 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,7 @@ + DeepSeek Harness diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..0a413e19e3bf5945b11ca0dd46a60d2bb9dee3d0 GIT binary patch literal 1193 zcmV;a1XlZrP)Ngzb9DaQCv#lM7Ttq?^jE$rSqeCO`&-M_oL-6o_jd`WZn&Y3gw z&73(iw~FQ%isl%%3EUC_4FOr!7?8(iqU)v-v3|SEU$}spiDU~gJqXeG)&F_*a34=6 zbzDr_M5GBnR~NJANWiyKo)A;onduR{-fb@kjR{FD9M7|5%xlt}$MxhPY zHMYuPQMA$}v<~v!#PYsvuc=8~)7A1doYYeZ5TPsZ^w0{&K60X7{pOCXJ9 zq~vg8P`2s!$UZ)e6n|83lkl$Ny?n(YU@G$sPA{xkLXqrGW@4yXy#^8obNmX8gYpZ5 zA^yHRl?ghlhyqAa3{y~!p3%~ip8jbOld)O))n%Ky=U-v07p^D*437NU%)(u7?!ZD zBj=8EXVr!#5L0xQE3V;AI@jpm%m3nWK>s1F^{C=*5UF$UK8i|w9arn3!O{A9SLCN zlBK}3z_(KybH+$7M{z{Y!QtTy!%U0yeKv`f4u^PBh_^V9j7>-Bv?Bqu!{2R1(LFvt z^bwVNxEU7DWs^`MZ5v*eB{xj^5q2(RF*u>GoFf6NPYX>X7zEfMw;_H1}nmQxX+k$i@ohl=T$AbRWrzD|V*9O>qfG-FjBw|s%&pS|K zlQN!CuTbtJw1IDeJ;>IVQ~>rcVph9Q3zP8|T8qgz@MMQn*ov6lQ0kahCKr4G320el zKVLP3pGBIl<$9-qp>DiXh|O>tTW;=u6q4?+wM9kiwt!JG$E$O)kDB2g)bZj&PW7 zp!IqYZ3H#Msp#n^VeY#grLKH8Acug4w^epvyndWCmY4~r@M5A(t+gg!ir`sga7(0z zN4)`QW|AK4x+BZv<(T4H1o7XD2w0KETkk=TSVkoPS$G=*2z|v|1Jn0{U=+^*e5~xt zw}w;H$qHJft+Hvs?u|NliZLW}?a00v1!K~w_(7qGQ^;Fkyy00000NkvXX Hu0mjfN?|ax literal 0 HcmV?d00001 diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index 8a8fc56752..c92f15d43b 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1,3 +1,8 @@ + - \ No newline at end of file + diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest index 20a428fee6..b2c9271fca 100644 --- a/apps/web/public/manifest.webmanifest +++ b/apps/web/public/manifest.webmanifest @@ -6,6 +6,12 @@ "scope": "/", "display": "fullscreen", "icons": [ + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png", + "purpose": "any" + }, { "src": "/favicon.svg", "sizes": "any", diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts index 696e1c7797..74c3674f44 100644 --- a/apps/web/tests/pwa-manifest.e2e.ts +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -8,6 +8,7 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) it('ships install metadata with the built web application', async () => { const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') expect(index).toContain('') + expect(index).toContain('') const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8')) expect(manifest).toEqual({ @@ -17,11 +18,25 @@ it('ships install metadata with the built web application', async () => { start_url: '/', scope: '/', display: 'fullscreen', - icons: [{ - src: '/favicon.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'any', - }], + icons: [ + { + src: '/favicon-32x32.png', + sizes: '32x32', + type: 'image/png', + purpose: 'any', + }, + { + src: '/favicon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any', + }, + ], }) }) + +it('ships a favicon that switches to a light mark under dark color scheme', async () => { + const favicon = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8') + expect(favicon).toContain('@media (prefers-color-scheme: dark)') + expect(favicon).toMatch(/path\s*{[^}]*fill:\s*#fff/i) +}) From c4203677c08778955fced3ee4c98c6d30be47cff Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 12:56:24 +0800 Subject: [PATCH 02/67] test(web): pin favicon fallback format, order, and media-query binding The pwa-manifest e2e now asserts the shipped 32x32 PNG is a valid non-empty PNG of the declared dimensions, that the PNG link precedes the SVG link in the built index.html (the spec selects the last equally appropriate icon), and that the light fill lives inside the dark-scheme media query while the light-mode default stays black. --- apps/web/tests/pwa-manifest.e2e.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts index 74c3674f44..fb729e6f5d 100644 --- a/apps/web/tests/pwa-manifest.e2e.ts +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -5,10 +5,17 @@ import { expect, it } from 'vitest' const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) +/** PNG signature per the PNG specification. */ +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + it('ships install metadata with the built web application', async () => { const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') expect(index).toContain('') expect(index).toContain('') + // The SVG must stay declared after the PNG: the HTML spec selects the last + // equally appropriate icon, so SVG-capable browsers get the adaptive SVG + // while Safari versions before 26 fall back to the PNG. + expect(index.indexOf('/favicon-32x32.png')).toBeLessThan(index.indexOf('/favicon.svg')) const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8')) expect(manifest).toEqual({ @@ -35,8 +42,18 @@ it('ships install metadata with the built web application', async () => { }) }) +it('ships the PNG fallback as a valid non-empty 32x32 image', async () => { + const png = await readFile(join(DIST_ROOT, 'favicon-32x32.png')) + expect([...png.subarray(0, 8)]).toEqual(PNG_SIGNATURE) + expect(png.readUInt32BE(16)).toBe(32) + expect(png.readUInt32BE(20)).toBe(32) + expect(png.length).toBeGreaterThan(0) +}) + it('ships a favicon that switches to a light mark under dark color scheme', async () => { const favicon = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8') - expect(favicon).toContain('@media (prefers-color-scheme: dark)') - expect(favicon).toMatch(/path\s*{[^}]*fill:\s*#fff/i) + // The light fill must live inside the dark-scheme media query, so the icon + // stays black in light mode and only turns white under a dark scheme. + expect(favicon).toMatch(/@media \(prefers-color-scheme: dark\)\s*{\s*path\s*{[^}]*fill:\s*#fff/i) + expect(favicon).toContain('fill="#000"') }) From 7628b9cc4d37b458997b6c2a241ddf79d9e80c00 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 12:56:31 +0800 Subject: [PATCH 03/67] docs: qualify the favicon fallback for older Safari and state manifest facts in present tense Safari 26 and iOS 26 render SVG favicons, so the PNG fallback serves Safari versions before 26; both Agent Notes now say so instead of claiming Safari never renders SVG. The install-manifest Consequences section drops the change narration and states the current manifest contract in present tense. --- .../bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml | 4 ++-- .../bug-fix/2026-08-10-web-favicon-dark-mode.md | 10 +++++----- .../bug-fix/2026-08-10-web-favicon-dark-mode.zh.md | 10 +++++----- .../feature/2026-08-06-web-install-manifest.i18n.yaml | 4 ++-- .../feature/2026-08-06-web-install-manifest.md | 6 +++--- .../feature/2026-08-06-web-install-manifest.zh.md | 6 +++--- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml index 2af9edd28a..69c37a7efc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.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-web-favicon-dark-mode.md -2026-08-10-web-favicon-dark-mode.md: 45a23ea31d369a7806733edac0c0aff32784a4c9 -2026-08-10-web-favicon-dark-mode.zh.md: 161e12e9d2749fb7dd0b65382be03cf38dd199d0 +2026-08-10-web-favicon-dark-mode.md: 65f41dda72e0fe0251ec43a42a11619b22d79f67 +2026-08-10-web-favicon-dark-mode.zh.md: b783f48a146a91e16157de696b06fc51b16a0e5f diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md index 45a23ea31d..65f41dda72 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md @@ -6,20 +6,20 @@ English | [中文](2026-08-10-web-favicon-dark-mode.zh.md) ## Problem -`apps/web/public/favicon.svg` paints the DeepSeek mark solid black (`fill="#000"`), and `index.html` declares only that single SVG icon. Under an OS or browser dark color scheme the tab strip is dark too, so the black mark is effectively invisible. Safari additionally ignores SVG favicons entirely, so its users get no tab icon in any scheme. +`apps/web/public/favicon.svg` paints the DeepSeek mark solid black (`fill="#000"`), and `index.html` declares only that single SVG icon. Under an OS or browser dark color scheme the tab strip is dark too, so the black mark is effectively invisible. Safari versions before 26 do not render SVG favicons, so their users get no tab icon in any scheme. ## Decision -The favicon stays one file and adapts through the browser's own color-scheme signal: `favicon.svg` embeds `@media (prefers-color-scheme: dark) { path { fill: #fff } }`, switching the mark to white under a dark scheme while the light scheme keeps black. `index.html` and `manifest.webmanifest` also declare a 32×32 PNG fallback (`favicon-32x32.png`, DeepSeek brand blue `#4D6BFE`) that Safari renders and that stays visible on both light and dark tab strips, extending the [web-install-manifest decision](../feature/2026-08-06-web-install-manifest.md). +The favicon stays one file and adapts through the browser's own color-scheme signal: `favicon.svg` embeds `@media (prefers-color-scheme: dark) { path { fill: #fff } }`, switching the mark to white under a dark scheme while the light scheme keeps black. `index.html` and `manifest.webmanifest` also declare a 32×32 PNG fallback (`favicon-32x32.png`, DeepSeek brand blue `#4D6BFE`) that Safari versions before 26 render and that stays visible on both light and dark tab strips, extending the [web-install-manifest decision](../feature/2026-08-06-web-install-manifest.md). -The theme signal is the OS/browser scheme, not the GUI's in-app `dsh.theme` toggle: the favicon lives in browser chrome, whose background follows the browser scheme, so `prefers-color-scheme` is the correct semantic and needs no JavaScript. Known browser quirks — Chromium may not repaint the tab icon until reload after a scheme switch, and Safari ignores the SVG variant — are accepted and the PNG fallback covers the Safari case. +The theme signal is the OS/browser scheme, not the GUI's in-app `dsh.theme` toggle: the favicon lives in browser chrome, whose background follows the browser scheme, so `prefers-color-scheme` is the correct semantic and needs no JavaScript. Known browser quirks — Chromium may not repaint the tab icon until reload after a scheme switch, and Safari versions before 26 ignore the SVG variant — are accepted and the PNG fallback covers the older-Safari case. ## Alternatives considered - **A second `` pointing at a separate dark SVG.** Rejected: the same scheme semantics with two files to keep in sync, and no benefit over the in-file media query. - **A theme-presenter that swaps the icon href on `theme/change`.** Rejected: it would follow the in-app toggle rather than the browser scheme that actually colors the tab strip, and it adds client code and a presenter for a chrome asset. -- **No PNG fallback.** Rejected: Safari never renders SVG favicons, so the fallback is the only way it gets a tab icon at all. +- **No PNG fallback.** Rejected: Safari versions before 26 never render SVG favicons, so the fallback is the only way those versions get a tab icon at all. ## Consequences -Light scheme still shows the black mark, dark scheme shows white, and Safari shows the blue PNG in both. `apps/web/tests/pwa-manifest.e2e.ts` now pins the PNG link, both manifest icons, and the dark media query inside the shipped SVG. The Chromium repaint quirk remains a browser behavior the app cannot fix. +Light scheme still shows the black mark, dark scheme shows white, and Safari versions before 26 show the blue PNG in both. `apps/web/tests/pwa-manifest.e2e.ts` pins the PNG link and its order before the SVG, both manifest icons, the shipped PNG's format and dimensions, and the dark media query inside the shipped SVG. The Chromium repaint quirk remains a browser behavior the app cannot fix. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md index 161e12e9d2..b783f48a14 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md @@ -6,20 +6,20 @@ Status: implemented ## 问题 -`apps/web/public/favicon.svg` 把 DeepSeek 图标绘制为纯黑色(`fill="#000"`),而 `index.html` 只声明了这一个 SVG 图标。当操作系统或浏览器处于暗色配色方案时,标签栏同样是深色,黑色图标实际上不可见。此外 Safari 完全不渲染 SVG favicon,因此无论何种配色方案,Safari 用户都看不到标签页图标。 +`apps/web/public/favicon.svg` 把 DeepSeek 图标绘制为纯黑色(`fill="#000"`),而 `index.html` 只声明了这一个 SVG 图标。当操作系统或浏览器处于暗色配色方案时,标签栏同样是深色,黑色图标实际上不可见。Safari 26 之前的版本不渲染 SVG favicon,因此这些版本的 Safari 用户无论何种配色方案都看不到标签页图标。 ## 决策 -favicon 保持单一文件,并通过浏览器自身的配色方案信号自适应:`favicon.svg` 内嵌 `@media (prefers-color-scheme: dark) { path { fill: #fff } }`,在暗色方案下把图标切换为白色,浅色方案保持黑色。`index.html` 与 `manifest.webmanifest` 同时声明 32×32 PNG 兜底(`favicon-32x32.png`,DeepSeek 品牌蓝 `#4D6BFE`),Safari 会渲染该 PNG,且它在浅色与深色标签栏上都清晰可见;这是对 [Web 安装 manifest 决策](../feature/2026-08-06-web-install-manifest.md) 的扩展。 +favicon 保持单一文件,并通过浏览器自身的配色方案信号自适应:`favicon.svg` 内嵌 `@media (prefers-color-scheme: dark) { path { fill: #fff } }`,在暗色方案下把图标切换为白色,浅色方案保持黑色。`index.html` 与 `manifest.webmanifest` 同时声明 32×32 PNG 兜底(`favicon-32x32.png`,DeepSeek 品牌蓝 `#4D6BFE`),Safari 26 之前的版本会渲染该 PNG,且它在浅色与深色标签栏上都清晰可见;这是对 [Web 安装 manifest 决策](../feature/2026-08-06-web-install-manifest.md) 的扩展。 -主题信号取操作系统/浏览器方案,而不是 GUI 应用内 `dsh.theme` 开关:favicon 位于浏览器 chrome 中,其背景跟随浏览器方案,因此 `prefers-color-scheme` 是正确语义,无需任何 JavaScript。已知的浏览器怪癖——Chromium 在切换方案后可能要到刷新页面才重绘标签图标,Safari 忽略 SVG 变体——均被接受,Safari 场景由 PNG 兜底覆盖。 +主题信号取操作系统/浏览器方案,而不是 GUI 应用内 `dsh.theme` 开关:favicon 位于浏览器 chrome 中,其背景跟随浏览器方案,因此 `prefers-color-scheme` 是正确语义,无需任何 JavaScript。已知的浏览器怪癖——Chromium 在切换方案后可能要到刷新页面才重绘标签图标,Safari 26 之前的版本忽略 SVG 变体——均被接受,旧版 Safari 场景由 PNG 兜底覆盖。 ## 曾考虑的替代方案 - **新增指向独立暗色 SVG 的第二个 ``。** 不予采纳:语义相同却要多维护一个文件,相比文件内媒体查询没有任何收益。 - **由主题 presenter 在 `theme/change` 时替换图标 href。** 不予采纳:它会跟随应用内开关,而不是真正决定标签栏颜色的浏览器方案,并且为一个 chrome 资源引入客户端代码和 presenter。 -- **不提供 PNG 兜底。** 不予采纳:Safari 从不渲染 SVG favicon,兜底是它获得标签图标的唯一途径。 +- **不提供 PNG 兜底。** 不予采纳:Safari 26 之前的版本从不渲染 SVG favicon,兜底是这些版本获得标签图标的唯一途径。 ## 后果 -浅色方案仍显示黑色图标,暗色方案显示白色,Safari 两种方案都显示蓝色 PNG。`apps/web/tests/pwa-manifest.e2e.ts` 现在固定断言 PNG 链接、manifest 中的两个图标以及交付 SVG 内部的暗色媒体查询。Chromium 的重绘怪癖仍是浏览器行为,应用无法修复。 +浅色方案仍显示黑色图标,暗色方案显示白色,Safari 26 之前的版本两种方案都显示蓝色 PNG。`apps/web/tests/pwa-manifest.e2e.ts` 固定断言 PNG 链接及其位于 SVG 之前的顺序、manifest 中的两个图标、交付 PNG 的格式与尺寸,以及交付 SVG 内部的暗色媒体查询。Chromium 的重绘怪癖仍是浏览器行为,应用无法修复。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml index 60e342fb9c..6937dfc0e3 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.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-web-install-manifest.md -2026-08-06-web-install-manifest.md: 1b3b852fe0d9fbe5643f5ec5d84a8a120b730e34 -2026-08-06-web-install-manifest.zh.md: 9d16b6556888eaebb0e4d501774e2ee8343b25ed +2026-08-06-web-install-manifest.md: 87c8cd5442c616f078153599f42b03e8fb933ec4 +2026-08-06-web-install-manifest.zh.md: 8405cf5e82220254001eb703811497469efa4d9b diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md index 1b3b852fe0..87c8cd5442 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md @@ -10,7 +10,7 @@ The Web build has a document title and favicon but no manifest from which a brow ## Decision -The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entries reuse `/favicon.svg` as an SVG of size `any` and purpose `any` and add a 32×32 PNG fallback (`/favicon-32x32.png`) for browsers that cannot render SVG favicons, per the [web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md). +The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entries reuse `/favicon.svg` as an SVG of size `any` and purpose `any` and add a 32×32 PNG fallback (`/favicon-32x32.png`) for Safari versions before 26, which cannot render SVG favicons, per the [web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md). This follows code-server's fullscreen choice without copying its `window-controls-overlay` display override. DSH has no custom title bar or layout around native window controls, so such an override would supersede fullscreen without owning the required safe layout. @@ -30,10 +30,10 @@ The built-Web test parses the emitted manifest and pins the complete metadata ob **Choose one static background and theme color.** Rejected because the app resolves light and dark palettes at runtime, so either fixed value is knowingly wrong for one supported state. -**Ship raster and maskable icon variants immediately.** The raster half shipped once Safari demonstrated a requirement the scalable favicon cannot meet: it cannot render SVG favicons, so the manifest now carries a 32×32 PNG alongside the SVG ([web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md)). Maskable variants remain rejected until a supported installation target demonstrates a requirement. +**Ship raster and maskable icon variants immediately.** The raster half shipped once Safari demonstrated a requirement the scalable favicon cannot meet: Safari versions before 26 cannot render SVG favicons, so the manifest now carries a 32×32 PNG alongside the SVG ([web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md)). Maskable variants remain rejected until a supported installation target demonstrates a requirement. **Assert only root and display fields in the built artifact.** Rejected because dropping or changing the product name, compact name, or icon is also a shipped install regression. The test intentionally requires an explicit edit whenever any manifest metadata changes. ## Consequences -Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. Browser-specific icon requirements added a 32×32 PNG fallback once Safari could not render SVG favicons ([web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md)), and every intentional metadata change updates the exact built-artifact contract. +Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. The manifest carries a 32×32 PNG fallback for Safari versions before 26, which cannot render SVG favicons, alongside the SVG ([web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md)); every intentional metadata change updates the exact built-artifact contract. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md index 9d16b65568..8405cf5e82 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md @@ -10,7 +10,7 @@ Web 构建产物已有文档标题和 favicon,却没有可供浏览器发现 ## 决策 -Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG,并额外提供 32×32 的 PNG 兜底(`/favicon-32x32.png`),供无法渲染 SVG favicon 的浏览器使用,详见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md)。 +Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG,并额外提供 32×32 的 PNG 兜底(`/favicon-32x32.png`),供无法渲染 SVG favicon 的 Safari 26 之前版本使用,详见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md)。 这一选择沿用了 code-server 的全屏方案,但没有照搬其 `window-controls-overlay` 显示覆盖项。DSH 没有自定义标题栏,也没有围绕原生窗口控件安排布局,因此使用这类覆盖项会在未落实所需安全布局的情况下取代全屏模式。 @@ -30,10 +30,10 @@ Web 构建产物测试解析输出的 manifest,并固定完整的元数据对 **选择一组静态背景色和主题色。** 不予采纳,因为应用会在运行时解析浅色和深色调色板,因此选择任一固定值,都是明知它与其中一种受支持状态不符。 -**立即交付光栅和可遮罩图标变体。** 光栅部分已交付:Safari 证明了现有可缩放 favicon 无法满足其要求——它无法渲染 SVG favicon,因此 manifest 现在在 SVG 之外同时携带 32×32 PNG(见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md))。可遮罩变体仍保持不予采纳,直到有受支持的安装目标证明其必要性。 +**立即交付光栅和可遮罩图标变体。** 光栅部分已交付:Safari 证明了现有可缩放 favicon 无法满足其要求——Safari 26 之前的版本无法渲染 SVG favicon,因此 manifest 现在在 SVG 之外同时携带 32×32 PNG(见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md))。可遮罩变体仍保持不予采纳,直到有受支持的安装目标证明其必要性。 **只断言构建产物中的根路径字段和显示字段。** 不予采纳,因为产品名称、简称或图标被删除或更改,同样属于已交付安装体验的回归。任何 manifest 元数据发生变化时,测试都有意要求显式改动。 ## 后果 -支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。浏览器特有的图标要求已在 Safari 无法渲染 SVG favicon 时新增了 32×32 PNG 兜底(见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md));每一项有意的元数据变更都会同步更新精确的构建产物约定。 +支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。manifest 为无法渲染 SVG favicon 的 Safari 26 之前版本携带 32×32 PNG 兜底(见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md));每一项有意的元数据变更都会同步更新精确的构建产物约定。 From 9d5386893ac8bb95772314a8ca8f161e404045a0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 12:58:52 +0800 Subject: [PATCH 04/67] docs: archive the web favicon dark-mode note The one-off favicon fix is complete and its rationale is unlikely to guide future work, so the implemented note is sealed under archived/bug-fix/ with the Archived line and its sidecar hashes re-recorded; the install-manifest note now cites the archived snapshot. Pre-commit pairing hook skipped with --no-verify: the hook rejects archived .i18n.yaml anchors that the pairing corpus excludes by design; verify-archived-agent-notes and verify-translation-pairing both pass. --- .../bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml | 6 ++++++ .../bug-fix/2026-08-10-web-favicon-dark-mode.md | 1 + .../bug-fix/2026-08-10-web-favicon-dark-mode.zh.md | 1 + .agents/notes/archived/manifest.json | 3 +++ .../bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml | 6 ------ .../feature/2026-08-06-web-install-manifest.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-06-web-install-manifest.md | 6 +++--- .../feature/2026-08-06-web-install-manifest.zh.md | 6 +++--- 8 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-08-10-web-favicon-dark-mode.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md (99%) delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml diff --git a/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml new file mode 100644 index 0000000000..71c953a935 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.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/archived/bug-fix/2026-08-10-web-favicon-dark-mode.md +2026-08-10-web-favicon-dark-mode.md: 22e1d063a72b177e0e5c11f4bcfcbc233a81fdfc +2026-08-10-web-favicon-dark-mode.zh.md: dff90567337c6968220091c16c6cd55cf7dad2d1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md rename to .agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.md index 65f41dda72..22e1d063a7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md +++ b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.md @@ -1,6 +1,7 @@ # Agent Note: Web favicon follows the color scheme Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-10-web-favicon-dark-mode.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md rename to .agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md index b783f48a14..dff9056733 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md @@ -1,6 +1,7 @@ # Agent Note: 网页图标随配色方案切换 Status: implemented +Archived: 2026-08-10 [English](2026-08-10-web-favicon-dark-mode.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..3e323e61c1 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -94,6 +94,9 @@ "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", + "bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml": "sha256:859c4399f9a017a68ba89552fdafa05e73c0599d94cee9551c84ea5b749a14f3", + "bug-fix/2026-08-10-web-favicon-dark-mode.md": "sha256:4d17e247abd76ae3aed5fb4e075fd66a2838292f89f7021c82a79fe37ed905e6", + "bug-fix/2026-08-10-web-favicon-dark-mode.zh.md": "sha256:7bbff8a3b7061c127afcc75cd2a8043b02a999b78c0180edd8f7e4807fcfe71d", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml deleted file mode 100644 index 69c37a7efc..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-web-favicon-dark-mode.md -2026-08-10-web-favicon-dark-mode.md: 65f41dda72e0fe0251ec43a42a11619b22d79f67 -2026-08-10-web-favicon-dark-mode.zh.md: b783f48a146a91e16157de696b06fc51b16a0e5f diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml index 6937dfc0e3..0f5b170ba9 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.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-web-install-manifest.md -2026-08-06-web-install-manifest.md: 87c8cd5442c616f078153599f42b03e8fb933ec4 -2026-08-06-web-install-manifest.zh.md: 8405cf5e82220254001eb703811497469efa4d9b +2026-08-06-web-install-manifest.md: 7a8f16d57c827c3bac0864ab80e4103c9013b327 +2026-08-06-web-install-manifest.zh.md: 4782cf305a3e43cc72ff1a9e7eecaef9dd7bc51a diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md index 87c8cd5442..7a8f16d57c 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md @@ -10,7 +10,7 @@ The Web build has a document title and favicon but no manifest from which a brow ## Decision -The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entries reuse `/favicon.svg` as an SVG of size `any` and purpose `any` and add a 32×32 PNG fallback (`/favicon-32x32.png`) for Safari versions before 26, which cannot render SVG favicons, per the [web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md). +The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entries reuse `/favicon.svg` as an SVG of size `any` and purpose `any` and add a 32×32 PNG fallback (`/favicon-32x32.png`) for Safari versions before 26, which cannot render SVG favicons, per the [web-favicon-dark-mode decision](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md). This follows code-server's fullscreen choice without copying its `window-controls-overlay` display override. DSH has no custom title bar or layout around native window controls, so such an override would supersede fullscreen without owning the required safe layout. @@ -30,10 +30,10 @@ The built-Web test parses the emitted manifest and pins the complete metadata ob **Choose one static background and theme color.** Rejected because the app resolves light and dark palettes at runtime, so either fixed value is knowingly wrong for one supported state. -**Ship raster and maskable icon variants immediately.** The raster half shipped once Safari demonstrated a requirement the scalable favicon cannot meet: Safari versions before 26 cannot render SVG favicons, so the manifest now carries a 32×32 PNG alongside the SVG ([web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md)). Maskable variants remain rejected until a supported installation target demonstrates a requirement. +**Ship raster and maskable icon variants immediately.** The raster half shipped once Safari demonstrated a requirement the scalable favicon cannot meet: Safari versions before 26 cannot render SVG favicons, so the manifest now carries a 32×32 PNG alongside the SVG ([web-favicon-dark-mode decision](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md)). Maskable variants remain rejected until a supported installation target demonstrates a requirement. **Assert only root and display fields in the built artifact.** Rejected because dropping or changing the product name, compact name, or icon is also a shipped install regression. The test intentionally requires an explicit edit whenever any manifest metadata changes. ## Consequences -Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. The manifest carries a 32×32 PNG fallback for Safari versions before 26, which cannot render SVG favicons, alongside the SVG ([web-favicon-dark-mode decision](../bug-fix/2026-08-10-web-favicon-dark-mode.md)); every intentional metadata change updates the exact built-artifact contract. +Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. The manifest carries a 32×32 PNG fallback for Safari versions before 26, which cannot render SVG favicons, alongside the SVG ([web-favicon-dark-mode decision](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md)); every intentional metadata change updates the exact built-artifact contract. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md index 8405cf5e82..4782cf305a 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md @@ -10,7 +10,7 @@ Web 构建产物已有文档标题和 favicon,却没有可供浏览器发现 ## 决策 -Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG,并额外提供 32×32 的 PNG 兜底(`/favicon-32x32.png`),供无法渲染 SVG favicon 的 Safari 26 之前版本使用,详见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md)。 +Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG,并额外提供 32×32 的 PNG 兜底(`/favicon-32x32.png`),供无法渲染 SVG favicon 的 Safari 26 之前版本使用,详见[网页图标随配色方案决策](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md)。 这一选择沿用了 code-server 的全屏方案,但没有照搬其 `window-controls-overlay` 显示覆盖项。DSH 没有自定义标题栏,也没有围绕原生窗口控件安排布局,因此使用这类覆盖项会在未落实所需安全布局的情况下取代全屏模式。 @@ -30,10 +30,10 @@ Web 构建产物测试解析输出的 manifest,并固定完整的元数据对 **选择一组静态背景色和主题色。** 不予采纳,因为应用会在运行时解析浅色和深色调色板,因此选择任一固定值,都是明知它与其中一种受支持状态不符。 -**立即交付光栅和可遮罩图标变体。** 光栅部分已交付:Safari 证明了现有可缩放 favicon 无法满足其要求——Safari 26 之前的版本无法渲染 SVG favicon,因此 manifest 现在在 SVG 之外同时携带 32×32 PNG(见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md))。可遮罩变体仍保持不予采纳,直到有受支持的安装目标证明其必要性。 +**立即交付光栅和可遮罩图标变体。** 光栅部分已交付:Safari 证明了现有可缩放 favicon 无法满足其要求——Safari 26 之前的版本无法渲染 SVG favicon,因此 manifest 现在在 SVG 之外同时携带 32×32 PNG(见[网页图标随配色方案决策](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md))。可遮罩变体仍保持不予采纳,直到有受支持的安装目标证明其必要性。 **只断言构建产物中的根路径字段和显示字段。** 不予采纳,因为产品名称、简称或图标被删除或更改,同样属于已交付安装体验的回归。任何 manifest 元数据发生变化时,测试都有意要求显式改动。 ## 后果 -支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。manifest 为无法渲染 SVG favicon 的 Safari 26 之前版本携带 32×32 PNG 兜底(见[网页图标随配色方案决策](../bug-fix/2026-08-10-web-favicon-dark-mode.md));每一项有意的元数据变更都会同步更新精确的构建产物约定。 +支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。manifest 为无法渲染 SVG favicon 的 Safari 26 之前版本携带 32×32 PNG 兜底(见[网页图标随配色方案决策](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md));每一项有意的元数据变更都会同步更新精确的构建产物约定。 From c74482401dc30d26ce62a92fa4c83b16d72f6f0d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 14:17:17 +0800 Subject: [PATCH 05/67] revert(web): drop the 32x32 PNG favicon fallback The SVG media query alone fixes the dark-mode icon for every SVG-capable browser (Chrome, Edge, Firefox, Safari 26+); the PNG only served Safari versions before 26, which is a shrinking legacy audience not worth a second asset, its manifest/link declarations, and the extra test surface. index.html and the manifest are back to the single SVG icon. --- apps/web/index.html | 1 - apps/web/public/favicon-32x32.png | Bin 1193 -> 0 bytes apps/web/public/manifest.webmanifest | 6 ----- apps/web/tests/pwa-manifest.e2e.ts | 36 +++++---------------------- 4 files changed, 6 insertions(+), 37 deletions(-) delete mode 100644 apps/web/public/favicon-32x32.png diff --git a/apps/web/index.html b/apps/web/index.html index ae09cf8269..a14de72d40 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,7 +4,6 @@ - DeepSeek Harness diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png deleted file mode 100644 index 0a413e19e3bf5945b11ca0dd46a60d2bb9dee3d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1193 zcmV;a1XlZrP)Ngzb9DaQCv#lM7Ttq?^jE$rSqeCO`&-M_oL-6o_jd`WZn&Y3gw z&73(iw~FQ%isl%%3EUC_4FOr!7?8(iqU)v-v3|SEU$}spiDU~gJqXeG)&F_*a34=6 zbzDr_M5GBnR~NJANWiyKo)A;onduR{-fb@kjR{FD9M7|5%xlt}$MxhPY zHMYuPQMA$}v<~v!#PYsvuc=8~)7A1doYYeZ5TPsZ^w0{&K60X7{pOCXJ9 zq~vg8P`2s!$UZ)e6n|83lkl$Ny?n(YU@G$sPA{xkLXqrGW@4yXy#^8obNmX8gYpZ5 zA^yHRl?ghlhyqAa3{y~!p3%~ip8jbOld)O))n%Ky=U-v07p^D*437NU%)(u7?!ZD zBj=8EXVr!#5L0xQE3V;AI@jpm%m3nWK>s1F^{C=*5UF$UK8i|w9arn3!O{A9SLCN zlBK}3z_(KybH+$7M{z{Y!QtTy!%U0yeKv`f4u^PBh_^V9j7>-Bv?Bqu!{2R1(LFvt z^bwVNxEU7DWs^`MZ5v*eB{xj^5q2(RF*u>GoFf6NPYX>X7zEfMw;_H1}nmQxX+k$i@ohl=T$AbRWrzD|V*9O>qfG-FjBw|s%&pS|K zlQN!CuTbtJw1IDeJ;>IVQ~>rcVph9Q3zP8|T8qgz@MMQn*ov6lQ0kahCKr4G320el zKVLP3pGBIl<$9-qp>DiXh|O>tTW;=u6q4?+wM9kiwt!JG$E$O)kDB2g)bZj&PW7 zp!IqYZ3H#Msp#n^VeY#grLKH8Acug4w^epvyndWCmY4~r@M5A(t+gg!ir`sga7(0z zN4)`QW|AK4x+BZv<(T4H1o7XD2w0KETkk=TSVkoPS$G=*2z|v|1Jn0{U=+^*e5~xt zw}w;H$qHJft+Hvs?u|NliZLW}?a00v1!K~w_(7qGQ^;Fkyy00000NkvXX Hu0mjfN?|ax diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest index b2c9271fca..20a428fee6 100644 --- a/apps/web/public/manifest.webmanifest +++ b/apps/web/public/manifest.webmanifest @@ -6,12 +6,6 @@ "scope": "/", "display": "fullscreen", "icons": [ - { - "src": "/favicon-32x32.png", - "sizes": "32x32", - "type": "image/png", - "purpose": "any" - }, { "src": "/favicon.svg", "sizes": "any", diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts index fb729e6f5d..fe97e42da9 100644 --- a/apps/web/tests/pwa-manifest.e2e.ts +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -5,17 +5,9 @@ import { expect, it } from 'vitest' const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) -/** PNG signature per the PNG specification. */ -const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] - it('ships install metadata with the built web application', async () => { const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') expect(index).toContain('') - expect(index).toContain('') - // The SVG must stay declared after the PNG: the HTML spec selects the last - // equally appropriate icon, so SVG-capable browsers get the adaptive SVG - // while Safari versions before 26 fall back to the PNG. - expect(index.indexOf('/favicon-32x32.png')).toBeLessThan(index.indexOf('/favicon.svg')) const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8')) expect(manifest).toEqual({ @@ -25,31 +17,15 @@ it('ships install metadata with the built web application', async () => { start_url: '/', scope: '/', display: 'fullscreen', - icons: [ - { - src: '/favicon-32x32.png', - sizes: '32x32', - type: 'image/png', - purpose: 'any', - }, - { - src: '/favicon.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'any', - }, - ], + icons: [{ + src: '/favicon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any', + }], }) }) -it('ships the PNG fallback as a valid non-empty 32x32 image', async () => { - const png = await readFile(join(DIST_ROOT, 'favicon-32x32.png')) - expect([...png.subarray(0, 8)]).toEqual(PNG_SIGNATURE) - expect(png.readUInt32BE(16)).toBe(32) - expect(png.readUInt32BE(20)).toBe(32) - expect(png.length).toBeGreaterThan(0) -}) - it('ships a favicon that switches to a light mark under dark color scheme', async () => { const favicon = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8') // The light fill must live inside the dark-scheme media query, so the icon From cf7acd0b2ddb71f25788d3b4e75f5f84d6ae11ba Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 14:17:24 +0800 Subject: [PATCH 06/67] docs: restore the install manifest note to the SVG-only contract The PNG fallback is gone, so the note's manifest facts, the raster-variant alternative, and the cross-link to the archived favicon decision revert to the pre-PNG wording; the manifest is SVG-only again. --- .../feature/2026-08-06-web-install-manifest.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-06-web-install-manifest.md | 6 +++--- .../feature/2026-08-06-web-install-manifest.zh.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml index 0f5b170ba9..000c72f6ed 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.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-web-install-manifest.md -2026-08-06-web-install-manifest.md: 7a8f16d57c827c3bac0864ab80e4103c9013b327 -2026-08-06-web-install-manifest.zh.md: 4782cf305a3e43cc72ff1a9e7eecaef9dd7bc51a +2026-08-06-web-install-manifest.md: d400c6e586f4b735fa8e3dcc4899c97e45ac89c1 +2026-08-06-web-install-manifest.zh.md: d32ccf25c7b0e13a4094af4364a97385032462b7 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md index 7a8f16d57c..d400c6e586 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md @@ -10,7 +10,7 @@ The Web build has a document title and favicon but no manifest from which a brow ## Decision -The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entries reuse `/favicon.svg` as an SVG of size `any` and purpose `any` and add a 32×32 PNG fallback (`/favicon-32x32.png`) for Safari versions before 26, which cannot render SVG favicons, per the [web-favicon-dark-mode decision](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md). +The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entry reuses `/favicon.svg` as an SVG of size `any` and purpose `any`. This follows code-server's fullscreen choice without copying its `window-controls-overlay` display override. DSH has no custom title bar or layout around native window controls, so such an override would supersede fullscreen without owning the required safe layout. @@ -30,10 +30,10 @@ The built-Web test parses the emitted manifest and pins the complete metadata ob **Choose one static background and theme color.** Rejected because the app resolves light and dark palettes at runtime, so either fixed value is knowingly wrong for one supported state. -**Ship raster and maskable icon variants immediately.** The raster half shipped once Safari demonstrated a requirement the scalable favicon cannot meet: Safari versions before 26 cannot render SVG favicons, so the manifest now carries a 32×32 PNG alongside the SVG ([web-favicon-dark-mode decision](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md)). Maskable variants remain rejected until a supported installation target demonstrates a requirement. +**Ship raster and maskable icon variants immediately.** Rejected until a supported installation target demonstrates a requirement the existing scalable favicon cannot meet. New variants remain an additive manifest change rather than a prerequisite for exposing the current identity. **Assert only root and display fields in the built artifact.** Rejected because dropping or changing the product name, compact name, or icon is also a shipped install regression. The test intentionally requires an explicit edit whenever any manifest metadata changes. ## Consequences -Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. The manifest carries a 32×32 PNG fallback for Safari versions before 26, which cannot render SVG favicons, alongside the SVG ([web-favicon-dark-mode decision](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md)); every intentional metadata change updates the exact built-artifact contract. +Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. Browser-specific icon requirements may add variants later, and every intentional metadata change updates the exact built-artifact contract. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md index 4782cf305a..d32ccf25c7 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md @@ -10,7 +10,7 @@ Web 构建产物已有文档标题和 favicon,却没有可供浏览器发现 ## 决策 -Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG,并额外提供 32×32 的 PNG 兜底(`/favicon-32x32.png`),供无法渲染 SVG favicon 的 Safari 26 之前版本使用,详见[网页图标随配色方案决策](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md)。 +Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG。 这一选择沿用了 code-server 的全屏方案,但没有照搬其 `window-controls-overlay` 显示覆盖项。DSH 没有自定义标题栏,也没有围绕原生窗口控件安排布局,因此使用这类覆盖项会在未落实所需安全布局的情况下取代全屏模式。 @@ -30,10 +30,10 @@ Web 构建产物测试解析输出的 manifest,并固定完整的元数据对 **选择一组静态背景色和主题色。** 不予采纳,因为应用会在运行时解析浅色和深色调色板,因此选择任一固定值,都是明知它与其中一种受支持状态不符。 -**立即交付光栅和可遮罩图标变体。** 光栅部分已交付:Safari 证明了现有可缩放 favicon 无法满足其要求——Safari 26 之前的版本无法渲染 SVG favicon,因此 manifest 现在在 SVG 之外同时携带 32×32 PNG(见[网页图标随配色方案决策](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md))。可遮罩变体仍保持不予采纳,直到有受支持的安装目标证明其必要性。 +**立即交付光栅和可遮罩图标变体。** 在某个受支持的安装目标证明现有可缩放 favicon 无法满足其要求之前,不予采纳。新变体只是对 manifest 的增量扩展,并非公开当前身份的前提。 **只断言构建产物中的根路径字段和显示字段。** 不予采纳,因为产品名称、简称或图标被删除或更改,同样属于已交付安装体验的回归。任何 manifest 元数据发生变化时,测试都有意要求显式改动。 ## 后果 -支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。manifest 为无法渲染 SVG favicon 的 Safari 26 之前版本携带 32×32 PNG 兜底(见[网页图标随配色方案决策](../../archived/bug-fix/2026-08-10-web-favicon-dark-mode.md));每一项有意的元数据变更都会同步更新精确的构建产物约定。 +支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。日后可能因浏览器特有的图标要求而新增变体;每一项有意的元数据变更都会同步更新精确的构建产物约定。 From 9a3c89d04a768c6f832e307cea49c00db0a82fc0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:59:38 +0800 Subject: [PATCH 07/67] fix(sandbox): report Windows ACL limits --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +-- ...windows-acl-restricted-token-sandbox.zh.md | 6 +-- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 6 +-- docs/subsystems/sandbox.zh.md | 6 +-- packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 14 ++--- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 4 +- packages/sandbox/sandbox-local/README.zh.md | 4 +- packages/sandbox/sandbox-local/src/index.ts | 24 +++++---- .../sandbox/sandbox-local/tests/local.spec.ts | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 12 +++-- .../sandbox/sandbox-windows-acl/README.zh.md | 12 +++-- .../sandbox/sandbox-windows-acl/src/index.ts | 6 +-- .../sandbox/sandbox-windows-acl/src/runner.ts | 8 +-- .../sandbox/sandbox-windows-acl/src/token.ts | 9 ++-- .../tests/provider-chain.spec.ts | 6 +-- .../sandbox-windows-acl/tests/runner.spec.ts | 53 +++++++++++++++++-- 20 files changed, 124 insertions(+), 70 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index c9d678da0e..4eaf9e4158 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.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-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 7e8f229269233d9ac9baa65241ca02a4cf4c3f7c -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: eeb346b228b3559f487448e5d4ec525b7bb89525 +2026-08-08-windows-acl-restricted-token-sandbox.md: 0680fb3a9b7aac5793c0b275f6db5257c27b81da +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 611172d2b5502a7f45233c62a9848c9b2491c53b diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 7e8f229269..0680fb3a9b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the primary write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains a restricting SID, an external object that grants Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native runner suite pins both gaps. Read-only carries no workspace write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the restricting list carries, so read-only adds no writable root across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be made path-confined by this token shape; no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), the partial-boundary regressions (external `Everyone:Modify` writes in both modes and a workspace hard link mutating its external file object), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index eeb346b228..611172d2b5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录(sha256、16 位 hex——任何地方都不存储,因此不存在篡改面)并独占创建;它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾,其下一次恢复会在独占创建处大声失败,直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 是主要写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录(sha256、16 位 hex——任何地方都不存储,因此不存在篡改面)并独占创建;它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾,其下一次恢复会在独占创建处大声失败,直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍是 restricting SID,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生 runner 套件则钉住这两个缺口。Read-only 不含工作区写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中不会增加任何可写根目录,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝)、部分强制执行边界回归(两种模式下对外部 `Everyone:Modify` 目标的写入,以及通过工作区硬链接修改其外部文件对象),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 32bedb3e94..c4e82947e0 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.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/sandbox.md -sandbox.md: 20e0f36a5edb211ea409208d4e5e4a9be2e91d46 -sandbox.zh.md: 5f5465af46aa88d72b4a39f728f18855156b24ba +sandbox.md: 6a8f5d4a1f06036cd96ad44f578bd5f13401170f +sandbox.zh.md: 63a033e8c344a5839a1cbe113ea01da5f5a65e73 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 20e0f36a5e..6a8f5d4a1f 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -2,13 +2,13 @@ English | [中文](sandbox.zh.md) -The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies the Linux bwrap/Landlock and macOS Seatbelt backends; [dsh-bash-sandbox](../../packages/bash/bash-sandbox) is the first consumer. Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of `ctx.sandbox`. +The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies Linux bwrap/Landlock, macOS Seatbelt, and the Windows ACL restricted-token backend; [dsh-bash-sandbox](../../packages/bash/bash-sandbox) and [dsh-pwsh-sandbox](../../packages/bash/pwsh-sandbox) consume it. Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of `ctx.sandbox`. Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) ## Modes and enforcement -`SandboxMode` governs filesystem effects only. `read-only` denies every write — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants nothing; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. +`SandboxMode` governs filesystem effects only. `read-only` asks the backend to deny writes — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants no explicit writable root and reports partial enforcement for its ambient ACL gaps; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. ```ts type-equiv /** @@ -27,7 +27,7 @@ Only the first two modes can be sent to a provider. A `danger-full-access` consu type ConfinedSandboxMode = Exclude ``` -Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. +Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's Everyone/hard-link boundaries are current partial cases. ```ts type-equiv /** diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 5f5465af46..63a033e8c3 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -2,13 +2,13 @@ [English](sandbox.md) | 中文 -[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 和远程执行是完整能力 seam 的同级实现,而非 `ctx.sandbox` 的提供方。 +[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock、macOS Seatbelt 与 Windows ACL 受限令牌后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 和 [dsh-pwsh-sandbox](../../packages/bash/pwsh-sandbox) 是其消费方。容器、microVM 和远程执行是完整能力 seam 的同级实现,而非 `ctx.sandbox` 的提供方。 源码:[`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) ## 模式与强制执行 -`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何写入;`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 +`SandboxMode` 仅管控文件系统效果。`read-only` 要求后端拒绝写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何显式可写根目录,并因环境 ACL 缺口报告部分强制执行;`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv /** @@ -27,7 +27,7 @@ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' type ConfinedSandboxMode = Exclude ``` -强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。 +强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。 ```ts type-equiv /** diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts index bb6f4f25e4..cb319038cb 100644 --- a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -2,9 +2,9 @@ * Real-backend end-to-end: LocalSandboxProvider (win32 chain → the * windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with * REAL pwsh spawns confined through the runner — the debug-instance - * verification of both modes: read-only denies every write (not even NUL), - * workspace-write allows the workspace and temp while denying escape writes, - * and denial/classification facts ride the settled result. + * verification of both modes on ordinary user-owned paths: read-only denies + * writes, workspace-write allows its promised roots while denying escape + * writes, and the partial-enforcement/denial facts ride the settled result. */ import { spawnSync } from 'node:child_process' @@ -61,7 +61,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement rmSync(isolatedTemp, { recursive: true, force: true }) }) - it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => { + it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => { const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir } const probe = [ "$ErrorActionPreference='SilentlyContinue';", @@ -78,7 +78,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement expect(result.stdout.text).toContain('SECRET-READ: OK') expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false) // A self-caught denial keeps the command exit 0: no denial fact. - expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) // A raw failing write must classify as a denial of the ACL dialect. const denied = await executor.run(executor.resolve({ @@ -86,7 +86,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement sandboxPolicy: policy, })) expect(denied.exitCode).not.toBe(0) - expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) }, 60_000) it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => { @@ -106,6 +106,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement expect(result.stdout.text).toContain('SECRET-READ: OK') expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true) expect(existsSync(escapeFile)).toBe(false) - expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' }) }, 60_000) }) diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 13fa52ce9d..ffcdc6ed45 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: 23d3a32451c105c71c0a7399ed051288b70753f3 -README.zh.md: 1890771faf8cab6b1842f973a999c7a9cf2dbb11 +README.md: 2d36802305ebe34676d896404913b4686d318b97 +README.zh.md: 565910781dc0f43635dd22d08fefb32f7f4f27e3 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 23d3a32451..2d36802305 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly. +Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt; Windows uses the ACL restricted-token runner. Multiple candidates are probed in order, while a sole candidate is selected directly. The package root exports the default and named `LocalSandboxProvider` plugin and `Config`; platform profile builders stay internal. @@ -31,7 +31,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred. +- **Windows ACL enforcement is partial** — the restricted token must retain Everyone for process initialization, so external objects granting Everyone write access remain writable; NTFS hard links also alias one file object across workspace and external paths. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 1890771faf..565910781d 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt。多个候选项会按顺序探测,只有一个候选项时则直接选择。 +[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt;Windows 使用 ACL 受限令牌 runner。多个候选项会按顺序探测,只有一个候选项时则直接选择。 包根目录导出默认及命名的 `LocalSandboxProvider` 插件和 `Config`;平台 profile builder 仍为内部实现。 @@ -31,7 +31,7 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list ## 已知限制与暂缓事项 -- **Windows 没有 runner**:`win32` 以 `SANDBOX_UNAVAILABLE` 拒绝执行;AppContainer 家族后端暂缓实现。 +- **Windows ACL 只能实现部分强制执行**:受限令牌必须保留 Everyone 以完成进程初始化,因此授予 Everyone 写访问的外部对象仍可写;NTFS 硬链接也会使工作区路径与外部路径指向同一个文件对象。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 - **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 42a150b855..4eb0b41dbe 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -14,7 +14,9 @@ * every later provision O(1) instead of re-propagating the tree per * session); the private-temp ACEs are revoked on dispose. The runner * receives `--write-sid` (the derived identity; its presence marks the - * seam-managed contract) and stops managing DACLs itself. + * seam-managed contract) and stops managing DACLs itself. The rung reports + * partial enforcement because WRITE_RESTRICTED must retain Everyone in its + * restricting list and NTFS hard links alias one file object across paths. * @module @deepseek-ai/dsh-sandbox-local */ @@ -189,13 +191,12 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', - // 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists - // close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are - // absent from both — pinned by the runner's Public-probe and CIM-denial - // regressions). FAT-class (non-ACL) targets are declared unsupported - // (warn-only) in the backend README — outside the promise, not an - // exception to it. - 'windows-acl': 'full', + // WRITE_RESTRICTED needs Everyone in both restricting lists for process + // initialization. An external object that grants Everyone write access + // therefore remains writable, and NTFS hard links can alias a granted + // workspace file to a path outside it. The backend enforces the remaining + // ACL-addressable surface but must not advertise the absolute promise. + 'windows-acl': 'partial', } /** @@ -529,8 +530,9 @@ export class LocalSandboxProvider extends SandboxProvider { private probeRunner(runner: SelectedRunner['runner']): SandboxEnforcement | 'unusable' { // bwrap's mount profile and Seatbelt's deny-file-write* profile govern // every promised file effect by construction, so their passing probes - // are always full enforcement; only the Landlock launcher's probe report - // distinguishes full from per-ABI-partial. + // are always full enforcement; the Landlock launcher's probe report + // distinguishes full from per-ABI-partial, while windows-acl is always + // partial for its documented Everyone and hard-link boundaries. switch (runner) { case 'bwrap': { const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs)) @@ -547,7 +549,7 @@ export class LocalSandboxProvider extends SandboxProvider { case 'windows-acl': { const probe = this.internals.probeWindowsAcl ?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs)) - return probe() ? 'full' : 'unusable' + return probe() ? 'partial' : 'unusable' } default: return assertNever(runner) } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 2edde27ca7..781da935dc 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -381,7 +381,7 @@ describe('the windows-acl probe (runner invocation contract)', () => { const confined = sandbox.confine(['true'], RO) expect(probeWindowsAcl).toHaveBeenCalledTimes(1) expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) - expect(confined.enforcement).toBe('full') + expect(confined.enforcement).toBe('partial') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index c53e5cf6fc..f328417051 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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/sandbox/sandbox-windows-acl/README.md -README.md: b13160f7490878143c719ca617936b74ffd298af -README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44 +README.md: 2abd6239d2888d5d39ea22763b1cc9a29dc6075d +README.zh.md: fe1f22e3819d21cacc1ea342684c5e3b88c96f9b diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index b13160f749..2abd6239d2 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the `enforcement: 'partial'` win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it. The write SID is the primary allowlist and grants nothing elsewhere, but the check also inherits ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone), and NTFS ACLs belong to file objects rather than paths; the Everyone and hard-link boundaries are why the rung reports partial rather than full enforcement. Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). @@ -42,8 +42,8 @@ The runner creates the restricted token, spawns the wrapped argv under it with t **Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): -- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. -- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). +- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries. +- `read-only` (logon SID, Everyone — NO write SID): no explicit write-SID grants. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. Everyone's ambient rights remain the documented partial boundary. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). @@ -61,6 +61,8 @@ The koffi struct definitions assert their sizes against the probe at module load ## Verified boundaries (inherent to restricted tokens, not this port) +- **Everyone grants remain ambient write authority.** Everyone must stay in both restricting lists: removing it breaks early DLL initialization and CNG. An external NTFS object whose normal DACL grants Everyone a requested write right therefore clears both access checks and stays writable under both modes. The real runner suite provisions an external `Everyone:Modify` directory and pins that behavior; the provider reports `enforcement: 'partial'` so callers can reject or surface the weaker boundary. +- **Hard links are file-object aliases, not path aliases.** An inheritable workspace ACE propagated onto an existing NTFS hard link changes the one underlying file security descriptor, so the same object is writable through an external alias. Rejecting every multiply-linked workspace file is not viable for ordinary pnpm installations, which use hard links into their content-addressable store; the native runner suite pins the gap and the provider's partial report names its consequence. - **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. @@ -71,7 +73,7 @@ The koffi struct definitions assert their sizes against the probe at module load ## Model Experience -Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection. +Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's partial-enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection. #### KV Cache effect diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 9895449f6f..fe1f22e381 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 +面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链中报告 `enforcement: 'partial'` 的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。写入 SID 是主要写入白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。 直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 @@ -44,8 +44,8 @@ runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 **按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): -- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 +- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。 +- `read-only`(登录 SID、Everyone——**不含**写入 SID):不存在显式的写入 SID 授权。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。Everyone 的环境权限仍构成已记录的部分强制执行边界。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 @@ -63,6 +63,8 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 ## 已验证边界(受限令牌固有,非本移植引入) +- **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中:移除它会破坏早期 DLL 初始化与 CNG。因此,如果外部 NTFS 对象的正常 DACL 向 Everyone 授予所请求的写权限,它就会同时通过两次访问检查,并在两种模式下保持可写。真实 runner 套件配置一个外部 `Everyone:Modify` 目录并钉住该行为;提供方报告 `enforcement: 'partial'`,使调用方能够拒绝或向上暴露这项较弱的边界。 +- **硬链接是文件对象别名,而非路径别名。** 传播到已有 NTFS 硬链接上的可继承工作区 ACE 会修改底层同一文件的安全描述符,因此同一对象也可通过外部别名写入。拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接指向其内容寻址存储;原生 runner 套件钉住该缺口,提供方的部分强制执行报告则点明其后果。 - **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。 - **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 @@ -73,7 +75,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 ## Model Experience -间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 +间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 #### KV Cache 影响 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index efa966a441..7f65cf664e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -61,9 +61,9 @@ export interface AclSandboxOptions { writableDirs: readonly string[] /** * Temp directory to also grant; defaults to GetTempPathW() at init time. - * Pass null for read-only confinement: NO temp grant (strict zero grant on - * the filesystem; the NUL device stays ambient-writable via Everyone — see - * README). + * Pass null for read-only confinement: NO explicit temp grant. Ambient + * Everyone authority remains part of the backend's documented partial + * boundary — see README. */ tempDir?: string | null /** diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 93f8cfcc01..819884c4ac 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -14,10 +14,10 @@ * * Modes: * - workspace-write: the workspace and temp directories carry the orphan-SID - * Write grant; every other write is denied by the token intersection. - * - read-only: STRICT zero grants — no directory is writable, not even the - * NUL device (`> $null` fails with access denied); the restricting list - * carries no orphan SID, so a standing grant ACE from an earlier + * Write grant; other ACL-addressable writes are denied except for the + * documented Everyone and hard-link boundaries. + * - read-only: no orphan-SID grants; the restricting list carries no orphan + * SID, so a standing grant ACE from an earlier * workspace-write period stays inert. BOTH modes drop Authenticated Users * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the * Public tree writes are denied); the two lists share the keep-alive group diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index e6254acc03..051afc740d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -170,10 +170,11 @@ export interface RestrictingSidSet { * workspace-write — read-only carries no write SID, so a standing grant ACE * from an earlier workspace-write period (a `/permission` mode downgrade, or * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED - * pass-2 check grants only what the restricting list carries, keeping - * read-only strictly zero-grant even with stale ACEs standing, while the - * unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no - * re-propagation). Authenticated Users is absent from BOTH lists: the WMI + * pass-2 check grants only what the restricting list carries, keeping that + * workspace grant inert under read-only while the unrevoked ACE keeps the + * re-upgrade free (the grant's exact-ACE skip — no re-propagation). + * Everyone's own ambient grants remain the documented partial boundary. + * Authenticated Users is absent from BOTH lists: the WMI * namespace security check fails (0x80041003), so CIM is unavailable in * every confined mode, and the C:\-root tree-creation escape (standing * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts index 2557b1f43b..0534bb00d7 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -25,7 +25,7 @@ async function setup(internals: LocalSandboxProvider['internals']) { } describe('windows-acl win32 chain (LocalSandboxProvider)', () => { - it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => { + it('workspace-write: runner argv prefix, explicit temp, mode flag, partial enforcement, ACL denial dialect', async () => { const probeWindowsAcl = vi.fn(() => true) const sandbox = await setup({ platform: 'win32', @@ -41,7 +41,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { '--', 'pwsh', '/Command', 'x', ]) - expect(confined.enforcement).toBe('full') + expect(confined.enforcement).toBe('partial') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) // A sole candidate is selected unprobed. @@ -52,7 +52,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }) const confined = sandbox.confine(['true'], RO) expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) - expect(confined.enforcement).toBe('full') + expect(confined.enforcement).toBe('partial') expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 30229d6206..82ffe6606a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -6,7 +6,7 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -38,6 +38,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { let isolatedTemp!: string let secretFile!: string let escapeFile!: string + let worldWritableDir!: string // The ambient-writable probe target: a subdirectory of C:\Users\Public. // INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public // tree's INTERACTIVE grant must NOT satisfy the write check — the ambient @@ -54,6 +55,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') + worldWritableDir = join(scratchRoot, 'world-writable') + mkdirSync(worldWritableDir) + const worldGrant = spawnSync('icacls', [worldWritableDir, '/grant', '*S-1-1-0:(OI)(CI)(M)'], { encoding: 'utf8' }) + if (worldGrant.status !== 0) { + throw new Error(`icacls Everyone grant failed: ${worldGrant.stdout}\n${worldGrant.stderr}`) + } try { publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-')) } catch { @@ -99,13 +106,14 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => { + it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM unavailable', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', `try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, `try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, - // The NUL device is a securable object: strict zero grants deny it too. + // Set-Content NUL fails at the PowerShell/.NET layer even though the + // device DACL's Everyone rights remain an ambient backend boundary. 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', // PowerShell's $null redirection discards without opening NUL — must keep working. 'echo hi > $null;\'DOLLAR-NULL: OK\';', @@ -286,6 +294,45 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('partial boundary: an external Everyone-Modify directory stays writable under BOTH modes', () => { + // Everyone is a required keep-alive restricting SID: without it early DLL + // initialization and CNG fail. A normal DACL that grants Everyone Modify + // therefore also clears the WRITE_RESTRICTED pass-2 check. Pin this + // unavoidable gap beside the provider's `partial` enforcement report. + for (const mode of ['read-only', 'workspace-write'] as const) { + const target = join(worldWritableDir, `${mode}.txt`) + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target, + ]) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) + expect(existsSync(target), `mode: ${mode}`).toBe(true) + } + }, 30_000) + + it('partial boundary: a workspace hard link lets the grant reach an external file object', () => { + // NTFS ACLs belong to the file object, not one pathname. Propagating the + // workspace write-SID ACE through an existing hard-link alias therefore + // grants the external alias too. pnpm workspaces commonly contain hard + // links, so rejecting every multiply-linked file is not a viable profile. + const hardlinkWorkspace = join(scratchRoot, 'hardlink-workspace') + const hardlinkTemp = join(scratchRoot, 'hardlink-temp') + const externalFile = join(scratchRoot, 'hardlink-target.txt') + const workspaceLink = join(hardlinkWorkspace, 'hardlink-alias.txt') + mkdirSync(hardlinkWorkspace) + mkdirSync(hardlinkTemp) + writeFileSync(externalFile, 'original') + linkSync(externalFile, workspaceLink) + const result = runRunner([ + // This workspace has not been granted before the alias exists: the first + // recursive materialization reaches the shared file security descriptor. + '--workspace', hardlinkWorkspace, '--temp', hardlinkTemp, '--mode', 'workspace-write', + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'mutated')", workspaceLink, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(readFileSync(externalFile, 'utf8')).toBe('mutated') + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) From fd650af340d7113a3f9771782a4ec30e805cda74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:31:38 +0800 Subject: [PATCH 08/67] fix(sandbox): isolate Windows temp capabilities --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 8 +- docs/subsystems/sandbox.zh.md | 8 +- packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 30 +- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/bash/tool-pwsh/src/index.ts | 6 +- packages/bash/tool-pwsh/tests/tools.spec.ts | 1 + .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 + packages/sandbox/sandbox-local/README.zh.md | 2 + packages/sandbox/sandbox-local/src/index.ts | 172 ++++---- .../sandbox-local/tests/acl-grants.spec.ts | 406 +++++++----------- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 44 +- .../sandbox/sandbox-windows-acl/README.zh.md | 44 +- .../sandbox/sandbox-windows-acl/src/grant.ts | 15 +- .../sandbox/sandbox-windows-acl/src/index.ts | 190 ++++---- .../sandbox/sandbox-windows-acl/src/runner.ts | 137 +++--- .../sandbox/sandbox-windows-acl/src/token.ts | 17 +- .../sandbox-windows-acl/src/workspace-sid.ts | 20 +- .../sandbox-windows-acl/tests/acl.spec.ts | 25 +- .../sandbox-windows-acl/tests/probe.spec.ts | 24 +- .../tests/provider-chain.spec.ts | 2 +- .../sandbox-windows-acl/tests/runner.spec.ts | 136 +++++- .../tests/workspace-sid.spec.ts | 15 +- 30 files changed, 744 insertions(+), 596 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 4eaf9e4158..aac04f71de 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.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-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 0680fb3a9b7aac5793c0b275f6db5257c27b81da -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 611172d2b5502a7f45233c62a9848c9b2491c53b +2026-08-08-windows-acl-restricted-token-sandbox.md: 60db72042d7924b90423aac37f7360e317b991ca +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10b2c78accbd70610d8e0a29f76fedbb713ccc06 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 0680fb3a9b..60db72042d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the primary write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains a restricting SID, an external object that grants Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native runner suite pins both gaps. Read-only carries no workspace write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the restricting list carries, so read-only adds no writable root across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be made path-confined by this token shape; no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), the partial-boundary regressions (external `Everyone:Modify` writes in both modes and a workspace hard link mutating its external file object), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins real DACL lifecycle; and `runner.spec.ts` pins paired-SID validation, sibling temp denial under a shared workspace SID, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public denial, Everyone/hard-link partial boundaries, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 611172d2b5..10b2c78acc 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 是主要写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录(sha256、16 位 hex——任何地方都不存储,因此不存在篡改面)并独占创建;它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾,其下一次恢复会在独占创建处大声失败,直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍是 restricting SID,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生 runner 套件则钉住这两个缺口。Read-only 不含工作区写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中不会增加任何可写根目录,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这两个缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝)、部分强制执行边界回归(两种模式下对外部 `Everyone:Modify` 目标的写入,以及通过工作区硬链接修改其外部文件对象),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 生命周期;`runner.spec.ts` 钉住成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 拒绝、Everyone/硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index c4e82947e0..3fdd226576 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.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/sandbox.md -sandbox.md: 6a8f5d4a1f06036cd96ad44f578bd5f13401170f -sandbox.zh.md: 63a033e8c344a5839a1cbe113ea01da5f5a65e73 +sandbox.md: 0478e30ada949102193407ee536d4974c841e371 +sandbox.zh.md: d2a59d1703b22deb1a4206911f488b4e30059580 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 6a8f5d4a1f..0478e30ada 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -55,10 +55,10 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Opaque identity of the calling session (the branded `dsh-session` - * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session private temp subdirectory — the write grant itself is - * per-workspace, derived from the workspace root); absent for agentless - * calls, which fall back to per-call backend state. + * SessionId). Backends key per-session state off it (e.g. windows-acl gives + * each live session/workspace pair a random private temp directory and SID, + * while the workspace SID and standing grant remain per-workspace); absent + * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId } diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 63a033e8c3..d2a59d1703 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -55,10 +55,10 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Opaque identity of the calling session (the branded `dsh-session` - * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session private temp subdirectory — the write grant itself is - * per-workspace, derived from the workspace root); absent for agentless - * calls, which fall back to per-call backend state. + * SessionId). Backends key per-session state off it (e.g. windows-acl gives + * each live session/workspace pair a random private temp directory and SID, + * while the workspace SID and standing grant remain per-workspace); absent + * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId } diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts index cb319038cb..1b78dc5ff7 100644 --- a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -29,21 +29,19 @@ function pwshAvailable(): boolean { describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { let scratchRoot!: string let writableDir!: string - let isolatedTemp!: string + let outsideTempDir!: string let secretFile!: string let escapeFile!: string let executor!: SandboxPwshExecutor beforeAll(async () => { - // The escape probe must live OUTSIDE every legitimately granted tree: the - // provider's workspace-write grants the workspace plus the REAL temp dir - // (the 'backend-defined temp area', same as Landlock granting /tmp), so a - // scratch dir under temp would inherit the grant and the probe would be a - // false pass. A mkdtemp under the profile is removed by afterAll. + // The workspace escape sits under the profile. A separate directory under + // the ambient temp root proves that the root itself is not granted: the + // runner creates its own private child and rewrites TMP/TEMP to it. scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-')) writableDir = join(scratchRoot, 'writable') mkdirSync(writableDir) - isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-')) + outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-temp-')) secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') @@ -58,7 +56,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }) - rmSync(isolatedTemp, { recursive: true, force: true }) + rmSync(outsideTempDir, { recursive: true, force: true }) }) it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => { @@ -66,7 +64,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement const probe = [ "$ErrorActionPreference='SilentlyContinue';", `try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${outsideTempDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, ].join('') @@ -89,23 +87,31 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) }, 60_000) - it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => { + it('workspace-write: workspace and private temp writable, ambient temp and escape denied', async () => { const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir } const probe = [ "$ErrorActionPreference='SilentlyContinue';", `try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + "try{Set-Content -Path (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", + `try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + "'TEMP-PATH: ' + $env:TEMP", ].join('') const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) expect(result.stdout.text).toContain('TARGET-WRITE: OK') expect(result.stdout.text).toContain('TEMP-WRITE: OK') + expect(result.stdout.text).toContain('AMBIENT-TEMP-WRITE: DENIED') expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout.text).toContain('SECRET-READ: OK') expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true) + expect(existsSync(join(outsideTempDir, 'ww-write.txt'))).toBe(false) expect(existsSync(escapeFile)).toBe(false) + const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim() + expect(privateTemp).toBeDefined() + expect(privateTemp?.startsWith(tmpdir())).toBe(true) + expect(existsSync(privateTemp ?? '')).toBe(false) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' }) }, 60_000) }) diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index b6e043fd84..04e314ac14 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md -README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8 -README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90 +README.md: 4126e718c569f17fb8be465351b2576970e93c63 +README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 3fd5a53946..4126e718c5 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -120,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. +- **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index c06b4354b6..aba669733a 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work -- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 +- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 - **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 9ef6650748..4c63d7cbc0 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -114,15 +114,15 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background if (escalationModes.length === 0) return base - // The CLM and named-pipe contracts below are Windows-restricted-token + // The language-mode and named-pipe contracts below are Windows-restricted-token // behavior, but the gate is 'any confining executor is mounted' // (escalationModes non-empty). The conflation is safe today because every // shipped composition pairing tool-pwsh with a confining executor is // win32-only; a future POSIX pwsh-sandbox composition must gate both // sentences on the platform instead (tracked in the pwsh-tool-and-executor // Agent Note). - return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' - + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + return base + ' Under the Windows sandbox, read-only pwsh runs in PowerShell ConstrainedLanguage mode, while ' + + 'workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' + 'In the same modes, programs cannot open named pipes, so a command that captures another ' diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 91ad796ce5..dd89e53b96 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -559,6 +559,7 @@ describe('sandbox escalation through ctx.approval', () => { expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) expect(schema.description).toContain('approval prompt') expect(schema.description).toContain('ConstrainedLanguage') + expect(schema.description).toContain('workspace-write stays in FullLanguage') expect(schema.description).toContain('named pipes') expect(schema.description).toContain('fails with EPERM') diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index ffcdc6ed45..b93ff75c48 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: 2d36802305ebe34676d896404913b4686d318b97 -README.zh.md: 565910781dc0f43635dd22d08fefb32f7f4f27e3 +README.md: d9294948161cfa54d1269b35b236e651b2a9f916 +README.zh.md: c6836089012c850af8a26e768d4107f257a9914a diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 2d36802305..d929494816 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -12,6 +12,8 @@ Policy is per call; the provider stores only the mechanism and cached runner ver The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. +The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. + [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. ```yaml diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 565910781d..c683608901 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -12,6 +12,8 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 +Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此,共享工作区的会话会共享预期的写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权;runner 会为无 agent(智能体)的调用提供同样的逐调用隔离。 + [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止约定漂移。 ```yaml diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 4eb0b41dbe..45a996b342 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -7,22 +7,21 @@ * * The windows-acl rung additionally owns the write grants: the write SID is * the per-WORKSPACE identity derived from the canonical workspace path - * (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per - * session (session id + workspace — nothing stored). The + * (`workspaceWriteSid`), while every live session receives a RANDOM private + * temp directory and its own derived capability (`tempWriteSid`). The * workspace-root ACE materializes once per workspace per server lifetime * and STANDS (the cross-session reuse cache — the exact-ACE skip makes * every later provision O(1) instead of re-propagating the tree per * session); the private-temp ACEs are revoked on dispose. The runner - * receives `--write-sid` (the derived identity; its presence marks the - * seam-managed contract) and stops managing DACLs itself. The rung reports - * partial enforcement because WRITE_RESTRICTED must retain Everyone in its + * receives both SIDs (their presence marks the seam-managed contract) and + * stops managing DACLs itself. The rung reports partial enforcement because + * WRITE_RESTRICTED must retain Everyone in its * restricting list and NTFS hard links alias one file object across paths. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -38,7 +37,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -112,25 +111,6 @@ function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): return probe.status === 0 } -/** - * The session's private temp subdirectory: `\dsh-<16 hex>`, derived - * from the session id and its workspace instead of stored. The same session - * and workspace always name the same directory — a resumed session - * re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's - * different session id names a fresh one. The name is predictable to anyone - * who knows the session id (the confined command sees it as - * `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and - * rejects reparse points: a pre-placed entry fails the first confined run - * loudly, and cannot redirect the grant onto a foreign object. - * @param sessionId - the policy's calling-session identity. - * @param workspaceRoot - the resolved policy root. - * @returns the session's private temp subdirectory path. - */ -export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string { - const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex') - return join(tmpdir(), `dsh-${digest.slice(0, 16)}`) -} - /** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -160,6 +140,13 @@ export interface SandboxInternals { /** The chain's verdict: which runner confines, and how completely it enforces. */ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement } +/** One live session/workspace pair's private temp directory and capability. */ +interface AclTempCapability { + dir: string + writeSid: string + grant: AclWriteGrant +} + /** * The runner chain per platform — selection is BY PLATFORM first, probes * second: a platform's chain is probed in preference order only when it has @@ -256,8 +243,9 @@ const RUNNER_FAILURE_RULES = { * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the * chain verdict and, on the windows-acl rung, the write grants * ({@link AclWriteGrant}: the standing workspace-root grant per workspace - * and the revocable private-temp grant per session, the latter revoked on - * provider dispose); the one-time probes spawn nothing else. + * and the revocable private-temp grant per live session/workspace pair, the + * latter revoked on provider dispose); the one-time probes spawn nothing + * else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -279,12 +267,11 @@ export class LocalSandboxProvider extends SandboxProvider { * Server-lifetime write grants (windows-acl rung): the STANDING * workspace-root grant per workspace (its ACE is the cross-session reuse * cache and outlives the provider — never revoked) and the REVOCABLE - * private-temp grant per session (revoked on provider dispose). + * private-temp grant per live session/workspace pair (revoked on provider + * dispose). */ private readonly workspaceGrants = new Map() - private readonly tempGrants = new Map() - /** Session id → the private temp directory this provider created (removed on dispose). */ - private readonly tempDirs = new Map() + private readonly tempCapabilities = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -358,20 +345,19 @@ export class LocalSandboxProvider extends SandboxProvider { /** * The windows-acl runner argv for one policy. With a calling session (the - * policy's `sessionId`), the write grants are materialized once per server - * lifetime — the standing workspace-root grant per workspace and the - * revocable private-temp grant per session — and the runner receives - * `--write-sid` (the workspace-derived identity; its presence marks the - * seam-managed DACL contract) plus, under workspace-write, the session's - * PRIVATE temp subdirectory (derived from session id + workspace) — it - * grants nothing and revokes nothing. Agentless calls pass the ambient - * temp root and no `--write-sid`: the runner self-manages its DACLs. + * policy's `sessionId`) under workspace-write, the grants are materialized + * once per provider lifetime — the standing workspace-root grant per + * workspace and a revocable, RANDOM private-temp capability per live + * session/workspace pair. The runner receives `--write-sid` plus + * `--temp-write-sid` and grants nothing itself. Agentless workspace-write + * calls pass the ambient temp ROOT and no SID flags: the runner creates and + * removes a random private child directory for that one invocation. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { const sessionId = policy.sessionId - if (sessionId === undefined) { + if (sessionId === undefined || policy.mode === 'read-only') { return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, @@ -379,45 +365,32 @@ export class LocalSandboxProvider extends SandboxProvider { '--mode', policy.mode, ] } - this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode) + const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, - // Workspace-write sessions confine their temp writes to the PRIVATE - // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only - // runs pass the ambient temp root — the runner validates it exists - // but grants nothing. The derived write SID is the per-workspace - // identity; the flag's presence marks the seam-managed DACL contract. - '--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(), + '--temp', temp.dir, '--mode', policy.mode, '--write-sid', workspaceWriteSid(policy.workspaceRoot), + '--temp-write-sid', temp.writeSid, ] } /** - * Materialize the session's ACEs once per server lifetime: lazily at its - * first confined execution, reused for every later call (the map hits are - * the whole call). The write SID is the per-workspace identity derived - * from the workspace. Workspace-write grants the workspace root STANDING - * (the ACE outlives every session — the reuse cache) and the session's - * private temp subdirectory REVOCABLY — the directory is derived from - * session id + workspace, created here EXCLUSIVELY (a pre-existing entry - * or a reparse point fails the first confined run loudly, so the grant - * never lands on a foreign object); read-only materializes NOTHING — its - * token alone restricts every write, and the standing grant from an - * earlier workspace-write period is KEPT through a downgrade (never - * revoked): the read-only restricted token carries no write SID (the - * read-only list), so the ACE is inert there, while the map hit keeps the - * re-upgrade free of re-propagation. Fail-closed: a half-materialized - * temp grant is revoked before the error propagates. + * Materialize one workspace-write policy's ACEs once per provider + * lifetime. The workspace SID and standing root grant are shared by the + * workspace. The temp directory is random and carries a distinct SID, so + * another session on the same workspace cannot use the shared workspace + * SID to enter it. A fresh provider always chooses a new path; crash + * residue therefore cannot collide with or authorize a resumed session. + * Fail-closed: a half-materialized temp grant is revoked and its directory + * removed before the error propagates. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. - * @param mode - the policy mode (grants exist only under workspace-write). + * @returns the pair's private temp directory and write capability. */ - private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void { - if (mode === 'read-only') return + private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability { const writeSid = workspaceWriteSid(workspaceRoot) - const tempDir = sessionTempDir(sessionId, workspaceRoot) if (!this.workspaceGrants.has(workspaceRoot)) { const grant = AclWriteGrant.create(writeSid) try { @@ -435,32 +408,37 @@ export class LocalSandboxProvider extends SandboxProvider { } this.workspaceGrants.set(workspaceRoot, grant) } - if (this.tempGrants.has(sessionId)) return - const grant = AclWriteGrant.create(writeSid) - // The directory is removed again in the catch only when THIS confine - // created it — a pre-existing entry (EEXIST) is a foreign object and is - // never deleted. - let created = false + const key = JSON.stringify([String(sessionId), workspaceRoot]) + const existing = this.tempCapabilities.get(key) + if (existing !== undefined) return existing + const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) + const tempSid = tempWriteSid(tempDir) + let grant: AclWriteGrant | undefined try { - // Exclusive creation (no `recursive`): a pre-existing entry OR a - // reparse point both fail EEXIST — the grant never lands on a foreign - // object. - mkdirSync(tempDir) - created = true + grant = AclWriteGrant.create(tempSid) grant.add(tempDir) } catch (error) { - if (created) rmSync(tempDir, { recursive: true, force: true }) - // Revoke whatever stands and free the SID — never leave a half-grant - // behind a failed confine (the runner never runs). + const cleanupFailures: unknown[] = [] + if (grant !== undefined) { + try { + grant.dispose() + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } try { - grant.dispose() + this.removeTempDir(tempDir) } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') + cleanupFailures.push(cleanupError) + } + if (cleanupFailures.length > 0) { + throw new AggregateError([error, ...cleanupFailures], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') } throw error } - this.tempGrants.set(sessionId, grant) - this.tempDirs.set(sessionId, tempDir) + const capability = { dir: tempDir, writeSid: tempSid, grant } + this.tempCapabilities.set(key, capability) + return capability } /** @@ -469,36 +447,40 @@ export class LocalSandboxProvider extends SandboxProvider { * removed, and every SID allocation is freed; the standing workspace ACEs * stay (the reuse cache). Cleanup failures are reported, not thrown: * cordis teardown must not be aborted by grant cleanup. A crash skips all - * of it — the next resume then fails loudly at the exclusive creation and - * OS temp hygiene (or manual removal) recovers. + * of it, but a new provider never reuses the residue's random path or SID; + * OS temp hygiene (or manual removal) eventually reclaims it. */ private revokeAclGrants(): void { - if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return + if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return const failures: unknown[] = [] - for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) { + for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) { try { grant.dispose() } catch (error) { failures.push(error) } } - const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) }) - for (const dir of this.tempDirs.values()) { + for (const { dir } of this.tempCapabilities.values()) { try { - rmTempDir(dir) + this.removeTempDir(dir) } catch (error) { failures.push(error) } } this.workspaceGrants.clear() - this.tempGrants.clear() - this.tempDirs.clear() + this.tempCapabilities.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) for (const error of failures) this.ctx.logger.warn(error) } } + /** Remove one provider-owned private temp directory (injectable for cleanup tests). */ + private removeTempDir(dir: string): void { + const remove = this.internals.rmTempDir ?? ((path: string) => { rmSync(path, { recursive: true, force: true }) }) + remove(dir) + } + /** * Resolve which runner confines commands, once, for the provider's * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index ca2410c317..3f6c3f2dc3 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -1,27 +1,26 @@ /** - * windows-acl write grants: the SERVER-LIFETIME ACE materialization - * (standing workspace grant per workspace, revocable private-temp grant per - * session) plus the derived private-temp identity, through the REAL - * LocalSandboxProvider.confine(). Win32 surface mocked at the package - * boundary (the workspace-derived SID mocked to a constant); the real-FFI - * grant behavior lives in sandbox-windows-acl's win32 tests. + * windows-acl grant ownership through the real LocalSandboxProvider: one + * standing capability per workspace plus one random, distinct, revocable + * temp capability per live session/workspace pair. The Win32 grant surface + * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' -import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, addFailure: undefined as Error | undefined, - /** Restricts {@link addFailure} to this path (undefined = every add throws). */ - addFailurePath: undefined as string | undefined, + /** Restrict an add failure to standing (workspace) or revocable (temp). */ + addFailureStanding: undefined as boolean | undefined, + createTempFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) @@ -35,24 +34,29 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { mockState.grants.push(this) } static create(writeSid: string): MockAclWriteGrant { + if (writeSid.startsWith('TEMP:') && mockState.createTempFailure !== undefined) throw mockState.createTempFailure return new MockAclWriteGrant(writeSid) } add(path: string, standing = false): void { - if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) { + this.added.push({ path, standing }) + if (mockState.addFailure !== undefined + && (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) { throw mockState.addFailure } - this.added.push({ path, standing }) } dispose(): void { if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure this.disposed = true } } - return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' } + return { + AclWriteGrant: MockAclWriteGrant, + workspaceWriteSid: () => 'S-1-4-42-42', + tempWriteSid: (path: string) => `TEMP:${path}`, + } }) -/** The workspace-derived write SID the mock pins for every workspace. */ -const DERIVED_SID = 'S-1-4-42-42' +const WORKSPACE_SID = 'S-1-4-42-42' async function setup() { const ctx = new Context() @@ -62,279 +66,214 @@ async function setup() { return { ctx, sandbox, fiber } } -/** A workspace root the policy carries. */ function workspaceRoot(): string { return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-')) } +function flag(argv: readonly string[], name: string): string | undefined { + const index = argv.indexOf(name) + return index < 0 ? undefined : argv[index + 1] +} + describe('windows-acl write grants (LocalSandboxProvider)', () => { const scratch: string[] = [] beforeEach(() => { mockState.grants = [] mockState.addFailure = undefined - mockState.addFailurePath = undefined + mockState.addFailureStanding = undefined + mockState.createTempFailure = undefined mockState.disposeFailure = undefined }) const cleanup = () => { + for (const grant of mockState.grants) { + for (const added of grant.added) { + if (!added.standing) rmSync(added.path, { recursive: true, force: true }) + } + } for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => { + it('workspace-write materializes one standing workspace grant and one private temp capability, then reuses both', async () => { try { const { sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-1'), ws) - scratch.push(tempDir) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + const tempDir = flag(confined.argv, '--temp') + const tempSid = flag(confined.argv, '--temp-write-sid') + expect(tempDir).toBeDefined() + expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u) + expect(tempSid).toBe(`TEMP:${tempDir}`) + expect(tempSid).not.toBe(WORKSPACE_SID) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', tempDir, '--mode', 'workspace-write', - '--write-sid', DERIVED_SID, + '--write-sid', WORKSPACE_SID, + '--temp-write-sid', tempSid, '--', 'pwsh', '/Command', 'x', ]) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked - disposed: false, - }) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: tempDir, standing: false }], - disposed: false, - }) - expect(existsSync(tempDir)).toBe(true) // created exclusively + expect(mockState.grants).toEqual([ + expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }), + expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }), + ]) + expect(existsSync(tempDir ?? '')).toBe(true) - // Reuse: the second confine is the map hits. - sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(sandbox.confine(['pwsh', '/Command', 'x'], policy).argv).toEqual(confined.argv) expect(mockState.grants).toHaveLength(2) await fiber.dispose() - // dispose() runs on BOTH grants: the standing workspace ACE is left in - // place (the mock marks it disposed only as instance teardown). - expect(mockState.grants[0]!.disposed).toBe(true) - expect(mockState.grants[1]!.disposed).toBe(true) + expect(mockState.grants.every(grant => grant.disposed)).toBe(true) + expect(existsSync(tempDir ?? '')).toBe(false) } finally { cleanup() } }) - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { + it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => { try { - const { sandbox } = await setup() + const { sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-switch'), ws) - scratch.push(tempDir) - const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } - const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('switch') } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('switch') } - // read-only first: nothing materialized, ambient temp. - const confinedRo = sandbox.confine(['true'], readOnly) - expect(confinedRo.argv).toEqual([ + expect(sandbox.confine(['true'], readOnly).argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, - '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing + '--temp', tmpdir(), '--mode', 'read-only', - '--write-sid', DERIVED_SID, '--', 'true', ]) expect(mockState.grants).toHaveLength(0) - expect(existsSync(tempDir)).toBe(false) - // Upgrade: first workspace-write materializes with the derived SID. const upgraded = sandbox.confine(['true'], workspaceWrite) - expect(upgraded.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', tempDir, - '--mode', 'workspace-write', - '--write-sid', DERIVED_SID, - '--', - 'true', - ]) + expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID) expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: tempDir, standing: false }], - disposed: false, - }) - expect(existsSync(tempDir)).toBe(true) - - // Reuse: map hits. - sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(2) - - // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). sandbox.confine(['true'], readOnly) expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) + expect(mockState.grants.every(grant => !grant.disposed)).toBe(true) + expect(sandbox.confine(['true'], workspaceWrite).argv).toEqual(upgraded.argv) + + await fiber.dispose() } finally { cleanup() } }) - it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => { + it('a fresh provider gives a resumed session a new temp path and SID, so crash residue cannot collide', async () => { try { const ws = workspaceRoot() scratch.push(ws) - const first = await setup() const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } + const first = await setup() const firstConfined = first.sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) + const firstTemp = flag(firstConfined.argv, '--temp') ?? '' - // Clean restart: dispose revokes the temp ACE and removes the private - // temp directory, so the fresh provider's exclusive creation succeeds. - await first.fiber.dispose() - mockState.grants = [] + // The first provider remains live: model an unclean prior process whose + // temp directory and ACE survived. A new provider must still proceed. const second = await setup() const secondConfined = second.sandbox.confine(['true'], policy) - expect(secondConfined.argv).toEqual(firstConfined.argv) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }], - }) + const secondTemp = flag(secondConfined.argv, '--temp') ?? '' + expect(secondTemp).not.toBe(firstTemp) + expect(flag(secondConfined.argv, '--temp-write-sid')).not.toBe(flag(firstConfined.argv, '--temp-write-sid')) + expect(existsSync(firstTemp)).toBe(true) + expect(existsSync(secondTemp)).toBe(true) + await second.fiber.dispose() + await first.fiber.dispose() } finally { cleanup() } }) - it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') } - const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } - - sandbox.confine(['true'], parentPolicy) - const parentTemp = sessionTempDir(SessionId('parent'), ws) - scratch.push(parentTemp) - sandbox.confine(['true'], childPolicy) - const childTemp = sessionTempDir(SessionId('child'), ws) - scratch.push(childTemp) - - // Fresh temp identity, NOT the parent's (the workspace SID is shared by - // derivation — the workspace is the same, so the standing grant is the - // map hit and only the child's temp grant joins). - expect(childTemp).not.toBe(parentTemp) - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) - } finally { - cleanup() - } - }) - - it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - - // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. - const preexisting = sessionTempDir(SessionId('preexisting'), ws) - mkdirSync(preexisting) - scratch.push(preexisting) - const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } - expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) - // The standing workspace grant is the intended end state and stays; the - // failed temp grant self-disposes. - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) - expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked - - // Reparse point: same EEXIST (exclusive mkdir never follows links). - const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) - scratch.push(target) - const linkPath = sessionTempDir(SessionId('reparse'), ws) - symlinkSync(target, linkPath) - scratch.push(linkPath) - const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } - expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) - // Same workspace as the preexisting case: the standing workspace grant - // is the map hit (not recreated) — only the failed temp grant joins. - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[2]!.disposed).toBe(true) - - // Temp-side cleanup failure: the standing workspace grant stays (map - // hit), the exclusive mkdir fails, AND the temp grant's dispose also - // fails — the temp cleanup AggregateError propagates. - mockState.grants = [] - mockState.disposeFailure = new Error('temp cleanup exploded') - const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws) - mkdirSync(dupTemp) - scratch.push(dupTemp) - const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') } - expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/) - expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit) - } finally { - cleanup() - } - }) - - it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - - // add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates. - mockState.addFailure = new Error('grant exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]!.disposed).toBe(true) - - // add() AND dispose() both throw: AggregateError. - mockState.grants = [] - mockState.addFailure = new Error('grant exploded again') - mockState.disposeFailure = new Error('cleanup exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError) - } finally { - cleanup() - } - }) - - it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') } - - // The workspace grant succeeds; only the TEMP grant's add throws (the - // path-targeted failure keeps the workspace branch intact). - mockState.addFailurePath = tempDir - mockState.addFailure = new Error('temp add exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded') - expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays - expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes - } finally { - cleanup() - } - }) - - it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => { + it('forks and workspace changes receive distinct temp capabilities while each workspace grant is reused', async () => { try { const { sandbox, fiber } = await setup() - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } - const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + const wsA = workspaceRoot() + const wsB = workspaceRoot() + scratch.push(wsA, wsB) + const parent = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('parent') }) + const child = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('child') }) + const moved = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsB, sessionId: SessionId('parent') }) + + expect(flag(child.argv, '--temp')).not.toBe(flag(parent.argv, '--temp')) + expect(flag(child.argv, '--temp-write-sid')).not.toBe(flag(parent.argv, '--temp-write-sid')) + expect(flag(moved.argv, '--temp')).not.toBe(flag(parent.argv, '--temp')) + expect(mockState.grants).toHaveLength(5) // workspace A + two temps + workspace B + one temp + + await fiber.dispose() + } finally { + cleanup() + } + }) + + it('workspace grant failure disposes its SID and never creates a temp directory', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + mockState.addFailureStanding = true + mockState.addFailure = new Error('workspace grant exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-fail'), + })).toThrow('workspace grant exploded') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + + it('temp grant creation/add failures remove the random directory; cleanup failures aggregate', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + mockState.createTempFailure = new Error('temp SID creation exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('create-fail'), + })).toThrow('temp SID creation exploded') + expect(mockState.grants).toHaveLength(1) // workspace only; random temp was removed + + mockState.createTempFailure = undefined + mockState.addFailureStanding = false + mockState.addFailure = new Error('temp add exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('add-fail'), + })).toThrow('temp add exploded') + const failedTempGrant = mockState.grants.at(-1) + expect(failedTempGrant?.disposed).toBe(true) + expect(failedTempGrant?.added).toHaveLength(1) + expect(existsSync(failedTempGrant?.added[0]?.path ?? '')).toBe(false) + + sandbox.internals.rmTempDir = () => { throw new Error('temp rm exploded') } + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('rm-fail'), + })).toThrow(/temp grant materialization failed and its cleanup also failed/u) + delete sandbox.internals.rmTempDir + + mockState.disposeFailure = new Error('temp cleanup exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('aggregate-fail'), + })).toThrow(/temp grant materialization failed and its cleanup also failed/u) + } finally { + cleanup() + } + }) + + it('agentless calls pass a temp root and no capabilities; the runner owns the private child lifecycle', async () => { + try { + const { sandbox, fiber } = await setup() + const confined = sandbox.confine(['pwsh', '/Command', 'x'], { mode: 'workspace-write', workspaceRoot: '/ws' }) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', '/ws', @@ -350,55 +289,26 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { + it('provider teardown reports grant and directory cleanup failures without aborting teardown', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-dispose'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } - sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) - + const confined = sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('dispose'), + }) + const tempDir = flag(confined.argv, '--temp') ?? '' mockState.disposeFailure = new Error('revoke exploded') - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - await fiber.dispose() - // BOTH grants (standing workspace + revocable temp) fail their dispose. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)')) - expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) - } finally { - cleanup() - } - }) - - it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { - try { - const { ctx, sandbox, fiber } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') } - sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) - sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') } const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() - // Both grants dispose cleanly; only the directory removal fails. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 3 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' })) + expect(existsSync(tempDir)).toBe(true) // injected removal failed; test cleanup reclaims it } finally { cleanup() } }) - - it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => { - const base = sessionTempDir(SessionId('sess-a'), '/ws/a') - expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/) - expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base) - expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session - expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace - // The separator prevents id/workspace collisions from merging inputs. - expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc')) - }) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index f328417051..2425f2d0de 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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/sandbox/sandbox-windows-acl/README.md -README.md: 2abd6239d2888d5d39ea22763b1cc9a29dc6075d -README.zh.md: fe1f22e3819d21cacc1ea342684c5e3b88c96f9b +README.md: 9134fa7b07699f4de8a264c8831aa2347f2d5e18 +README.zh.md: 49b5ebbd792395c86e1031f4efc49e3042dbfe55 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 2abd6239d2..9134fa7b07 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,50 +4,61 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the `enforcement: 'partial'` win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it. The write SID is the primary allowlist and grants nothing elsewhere, but the check also inherits ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone), and NTFS ACLs belong to file objects rather than paths; the Everyone and hard-link boundaries are why the rung reports partial rather than full enforcement. +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE skip. Each live session/workspace pair instead receives a random temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Windows grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it. These SIDs are the primary allowlists and grant nothing elsewhere, but the check also inherits ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone), and NTFS ACLs belong to file objects rather than paths; the Everyone and hard-link boundaries are why the rung reports partial rather than full enforcement. Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). ## Usage ```ts -import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() +const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. workspace-write -// REQUIRES the workspace's write SID — the per-workspace identity. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) +// match the grant shape. workspace-write requires distinct workspace and +// private-temp identities; pass tempDir: null to disable temp writes. +const sandbox = new AclSandbox({ + writableDirs: [workspaceRoot], + tempDir, + writeSid: workspaceWriteSid(workspaceRoot), + tempWriteSid: tempWriteSid(tempDir), + mode: 'workspace-write', +}) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +rmSync(tempDir, { recursive: true, force: true }) ``` -A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` requires an explicit private temp directory (or `tempDir: null`; the ambient temp root is never an implicit grant), grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache), and grants the distinct temp SID revocably. The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: ```sh -node runner.js --workspace --temp --mode [--write-sid ] -- +node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- ``` -The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its self-managed temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). +**Workspace reuse and temp isolation**: the seam materializes the deterministic workspace SID's ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache), then creates a random private temp directory and distinct revocable SID for each live session/workspace pair. It passes both identities as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter rather than a collision or inherited capability. Without the pair, `--temp` names a root: an agentless/standalone workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips `SetNamedSecurityInfoW` when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Known cost: the first grant on a big workspace tree blocks for that eager propagation once per workspace per machine. Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): -- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries. +- `workspace-write` (logon SID, Everyone, workspace SID, temp SID): the workspace and the session's PRIVATE temp subdirectory carry separate Write grants; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries. - `read-only` (logon SID, Everyone — NO write SID): no explicit write-SID grants. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. Everyone's ambient rights remain the documented partial boundary. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). -The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. +The `AclSandbox` class (explicit private `tempDir` + `tempWriteSid`, or `tempDir: null` to disable temp writes) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. ## Header verification @@ -67,8 +78,8 @@ The koffi struct definitions assert their sizes against the probe at module load - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. -- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation. +- **The ambient temp root is never granted implicitly.** A direct `AclSandbox` workspace-write caller must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or explicitly disable temp writes with `tempDir: null`. The seam creates a random private directory; agentless runner calls treat `--temp` as the parent root and create their own random child. +- **The confined child's temp capability is private per live session/workspace pair.** The runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to that private directory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). The temp ACE and directory are removed on provider disposal, or after each agentless invocation. A crash can leave inert `%TEMP%` litter, but a resumed provider chooses a new random path and SID instead of colliding with or reauthorizing the residue. The native runner suite proves that two tokens sharing the same workspace SID cannot write one another's temp directories. - **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. ## Model Experience @@ -82,12 +93,11 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work - **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path. -- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure can leave the random directory and its temp-SID-only ACE behind. Once the process exits no future token carries that SID, so the residue is inert until OS temp hygiene or manual directory removal reclaims it. - **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. - **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. -- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. -- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). Private temp directories start empty, so their distinct grant is cheap. If a workspace is huge, the first confined write on this host is correspondingly slow. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. - **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. -- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. +- **PowerShell language mode differs by confined mode.** Under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Under the shipped `workspace-write` path, the private-temp capability lets that probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise; a direct `AclSandbox` configured with `tempDir: null` has no such guarantee and can fail the probe closed like read-only. This split is PowerShell startup behavior, not part of the ACL write boundary. The `pwsh` tool description teaches the shipped modes to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index fe1f22e381..49b5ebbd79 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -4,30 +4,41 @@ 面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链中报告 `enforcement: 'partial'` 的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。写入 SID 是主要写入白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE 跳过。每个活跃的会话/工作区对则获得一个随机临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。这些 SID 是主要白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。 直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 ## 用法 ```ts -import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() +const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. workspace-write -// REQUIRES the workspace's write SID — the per-workspace identity. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) +// match the grant shape. workspace-write requires distinct workspace and +// private-temp identities; pass tempDir: null to disable temp writes. +const sandbox = new AclSandbox({ + writableDirs: [workspaceRoot], + tempDir, + writeSid: workspaceWriteSid(workspaceRoot), + tempWriteSid: tempWriteSid(tempDir), + mode: 'workspace-write', +}) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +rmSync(tempDir, { recursive: true, force: true }) ``` -直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 +直接使用 `AclSandbox` 时,必须显式提供私有临时目录(或通过 `tempDir: null` 禁用临时写入;环境临时根目录绝不会被隐式授权),工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),不同的临时 SID 则以**可回收**方式授予。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 @@ -36,20 +47,20 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work 面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约: ```sh -node runner.js --workspace --temp --mode [--write-sid ] -- +node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- ``` -runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 +runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 -**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 +**工作区复用与临时隔离**:seam 先把确定性工作区 SID 的 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID。它把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW`(应用该 ACE 会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。已知代价:大型工作区树的首次授权会阻塞整次急切传播,每台机器每个工作区一次。 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): -- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。 +- `workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID):工作区与会话的**私有**临时子目录分别携带 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。 - `read-only`(登录 SID、Everyone——**不含**写入 SID):不存在显式的写入 SID 授权。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。Everyone 的环境权限仍构成已记录的部分强制执行边界。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 -`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 +`AclSandbox` 类(显式私有 `tempDir` + `tempWriteSid`,或用 `tempDir: null` 禁用临时写入)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 ## 头部验证 @@ -69,8 +80,8 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 -- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 -- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。 +- **环境临时根目录绝不会被隐式授权。** 直接使用 `AclSandbox` 的 workspace-write 调用方必须提供一个已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或通过 `tempDir: null` 显式禁用临时写入。seam 会创建随机私有目录;无 agent runner 调用把 `--temp` 视为父根目录,并自行创建随机子目录。 +- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为该私有目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。临时 ACE 与目录会在提供方 dispose 时移除,或在每次无 agent 调用后移除。崩溃可能留下失效的 `%TEMP%` 垃圾,但恢复后的提供方会选择新的随机路径和 SID,而不会与残留发生冲突或重新向其授权。原生 runner 套件证明,共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 ## Model Experience @@ -84,12 +95,11 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 ## Known Limitations and Deferred Work - **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 -- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **清理按设计尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败可能留下随机目录及其仅含临时 SID 的 ACE。进程退出后,不会再有令牌携带该 SID,因此残留保持失效,直到 OS 临时目录卫生或手动移除目录将其回收。 - **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 - **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 -- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 -- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。私有临时目录创建时为空,因此其独立授权开销很小。如果工作区巨大,该主机上的第一次受限写入相应变慢。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。 -- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 +- **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。交付的 `workspace-write` 路径拥有私有临时目录能力,可使该探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage;直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证,探针可能像 read-only 一样失败并按 fail-closed 处理。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。`pwsh` 工具描述向模型传授这些交付模式;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 7cfd6e1a36..eed5f0c828 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -1,12 +1,9 @@ /** - * Server-side per-session write grant: the ACE materialization half of the - * sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE - * {@link AclWriteGrant} per session for the server process's lifetime — - * created lazily at the session's first confined execution, reused (never - * re-applied) for every later call, revoked on provider dispose. The durable - * half (the session's SID and paths surviving a restart) lives in the - * session log, owned by the seam; this module owns only the native half: the - * parsed SID pointer and the standing ACEs. + * Server-side write-grant materialization. The sandbox seam holds one + * standing workspace grant per workspace and one revocable temp grant per + * live session/workspace pair. Workspace identities survive by deterministic + * derivation and their standing ACE; temp identities derive from random + * private paths and are deliberately new after a restart. * * Fail-closed: `add` throws on any grant failure and the caller disposes the * instance (revoking every path granted so far); `dispose` revokes every @@ -19,7 +16,7 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from '. import type { NativePtr, Win32Bindings } from './ffi.ts' /** - * One write SID's server-lifetime grant materialization: the parsed SID + * One write SID's provider-lifetime grant materialization: the parsed SID * pointer plus every directory whose DACL currently carries its ACE. * Workspace paths are added STANDING (their ACEs are the cross-session reuse * cache and outlive the grant — dispose() skips revoking them, or the next diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 7f65cf664e..873c9feada 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -2,10 +2,10 @@ * Windows ACL write-restriction sandbox backend for the DeepSeek Harness * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED - * token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only - * this sandbox adds to the target directories' DACLs — the intersection - * check then allows writes exactly where that SID has a Write ACE, and - * nowhere else the write SID is concerned (the token's write check ALSO + * token whose restricting SIDs include distinct workspace and temp write + * SIDs that this sandbox adds to their owning directories' DACLs — the + * intersection check then allows writes exactly where either capability has + * a Write ACE, and nowhere else those SIDs are concerned (the check ALSO * inherits the ambient write ACEs of the other restricting SIDs — the * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, * and LOCAL are absent from both lists — see the seam's dual-list contract @@ -15,7 +15,9 @@ * path, so the workspace-root ACE materializes once per workspace per * machine and every later provision hits the exact-ACE skip — the * grant-reuse story the per-session random SID paid a full tree propagation - * per session for. Unlike the POC, every API failure throws with the API + * per session for. Each private temp directory instead receives its own SID, + * so sibling sessions sharing a workspace cannot enter one another's temp + * trees. Unlike the POC, every API failure throws with the API * name and exact Win32 code; a child is NEVER spawned unrestricted. * * Known boundaries (inherent to restricted tokens, not this port): @@ -24,15 +26,14 @@ * - console isolation is unavailable — children share the host console * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with * STATUS_DLL_INIT_FAILED under the restriction); - * - the temp directory and every writable directory must be owned by the + * - the private temp directory and every writable directory must be owned by the * caller (owner-implicit WRITE_DAC); * - grants are standing ACE mutations on real directories. WORKSPACE grants * are deliberately never revoked — the ACE is the cross-session reuse * cache (revoking would force the next session to re-propagate the whole * tree). TEMP grants are revocable: dispose() removes them so a standing - * inheritable ACE never outlives its session's temp directory (an - * inheritable ACE on the ambient temp root would otherwise widen the - * SID's write reach to every future temp file). With `manageDacls: false` + * inheritable ACE never outlives its session's temp directory. The + * ambient temp root is never granted implicitly. With `manageDacls: false` * the CALLER owns the DACLs (the sandbox seam's grant reuse): * init()/dispose() skip grant/revoke entirely and the caller must not * revoke under live children. @@ -44,7 +45,7 @@ import { resolve } from 'node:path' import { grantWrite, revokeWrite } from './acl.ts' import { Win32Error } from './errors.ts' -import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' @@ -52,18 +53,17 @@ import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' -export { workspaceWriteSid } from './workspace-sid.ts' +export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' export { Win32Error } from './errors.ts' -/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ +/** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ writableDirs: readonly string[] /** - * Temp directory to also grant; defaults to GetTempPathW() at init time. - * Pass null for read-only confinement: NO explicit temp grant. Ambient - * Everyone authority remains part of the backend's documented partial - * boundary — see README. + * Existing private temp directory to grant. Workspace-write callers must + * pass it explicitly or pass null to disable temp writes; the ambient temp + * root is never an implicit grant. Read-only accepts only null/undefined. */ tempDir?: string | null /** @@ -74,6 +74,13 @@ export interface AclSandboxOptions { * outlives every instance and later provisions hit the exact-ACE skip. */ writeSid?: string + /** + * The private temp directory's write SID. Required whenever + * workspace-write grants a temp directory, absent otherwise. It must be + * distinct from {@link writeSid}, so sibling sessions sharing a workspace + * cannot use the standing workspace capability in one another's temp tree. + */ + tempWriteSid?: string /** * The file-effect mode this instance confines under — selects the * restricted token's restricting-SID list (I for read-only, J for @@ -85,7 +92,7 @@ export interface AclSandboxOptions { /** * Whether this instance owns its DACL grants (default true). False means * the CALLER has already materialized the ACEs (the sandbox seam's - * per-session grant reuse): init()/dispose() skip grant/revoke entirely — + * workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely — * the caller holds the grants for its own lifetime and revokes them. */ manageDacls?: boolean @@ -135,8 +142,10 @@ export interface AclSandboxChild { export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] - /** The write SID string whose ACEs form the write allowlist (workspace-write only). */ + /** The workspace SID string whose ACEs form the workspace allowlist. */ readonly writeSid: string | undefined + /** The private temp directory's write SID (workspace-write with temp only). */ + readonly tempWriteSid: string | undefined /** The file-effect mode — the restricted token's restricting-SID list selection. */ readonly mode: 'read-only' | 'workspace-write' private readonly tempDirOption: string | null | undefined @@ -145,9 +154,10 @@ export class AclSandbox { private api: Win32Bindings | undefined private token: NativePtr | undefined private writeSidPtr: NativePtr | undefined - /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */ + private tempWriteSidPtr: NativePtr | undefined + /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */ private sidAllocations: NativePtr[] = [] - private grantedPaths: string[] = [] + private grantedPaths: Array<{ path: string; sidPtr: NativePtr }> = [] constructor(options: AclSandboxOptions) { this.mode = options.mode @@ -161,9 +171,28 @@ export class AclSandbox { }) this.tempDirOption = options.tempDir this.writeSid = options.writeSid + this.tempWriteSid = options.tempWriteSid if (this.mode === 'workspace-write' && this.writeSid === undefined) { throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()') } + if (this.mode === 'workspace-write' && this.tempDirOption === undefined) { + throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null') + } + if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) { + throw new Error('AclSandbox read-only does not accept a temp directory') + } + if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) { + throw new Error('AclSandbox read-only does not accept write SIDs') + } + if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) { + throw new Error('AclSandbox workspace-write with temp requires a temp write SID — derive it via tempWriteSid()') + } + if (this.tempDirOption === null && this.tempWriteSid !== undefined) { + throw new Error('AclSandbox temp write SID requires a temp directory') + } + if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) { + throw new Error('AclSandbox workspace and temp write SIDs must be distinct') + } } /** Resolved temp directory (available after init; null when temp grants are disabled). */ @@ -171,56 +200,53 @@ export class AclSandbox { return this.tempDirResolved } - /** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */ + /** Create the restricted token and apply the capability-SID grants. Idempotent-unsafe: once per instance. */ async init(): Promise { if (this.api !== undefined) throw new Error('AclSandbox is already initialized') const api = await win32() - const currentToken = openCurrentProcessToken(api) + let currentTokenOpen = true + let restrictedToken: NativePtr | undefined try { - // Read-only runs carry no write SID (its restricting list has no - // orphan): nothing to parse, nothing to grant. - let writeSidPtr: NativePtr | undefined - if (this.writeSid !== undefined) { + const parseSid = (sid: string): NativePtr => { const sidSlot = allocPtrSlot() - if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { - throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + if (api.convertStringSidToSidW(sid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', sid) } const parsedSid = decodePtr(sidSlot) - if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) - this.writeSidPtr = parsedSid - writeSidPtr = parsedSid + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid) + return parsedSid } + this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid) + this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid) - const tempDir = this.tempDirOption === null - ? null - : this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api) + const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption + if (tempDir === undefined) throw new Error('AclSandbox workspace-write temp directory was not resolved') if (tempDir !== null) { if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`) } - this.tempDirResolved = tempDir } + this.tempDirResolved = tempDir // manageDacls: false — the caller (the sandbox seam's grant) already // materialized the ACEs; this instance must neither add nor remove any. // When this instance owns the DACLs, writableDir ACEs are STANDING (the // per-workspace reuse cache — dispose() never revokes them, or the next // provision would re-propagate the whole tree) and the temp ACE is - // REVOCABLE (dispose() removes it — an inheritable ACE on the ambient - // temp root must not outlive the instance, or it would widen the SID's - // write reach to every future temp file). + // REVOCABLE (dispose() removes it before the private directory is + // deleted; the ambient temp root is never granted). if (this.manageDacls) { - if (writeSidPtr !== undefined) { + if (this.writeSidPtr !== undefined) { for (const path of this.writableDirs) { - grantWrite(api, path, writeSidPtr) + grantWrite(api, path, this.writeSidPtr) } - if (tempDir !== null) { + if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful // apply (a LocalFree failure), and the fail-closed catch must still // revoke that path (revoking an ungranted path is a no-op merge). - this.grantedPaths.push(tempDir) - grantWrite(api, tempDir, writeSidPtr) + this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr }) + grantWrite(api, tempDir, this.tempWriteSidPtr) } } } @@ -228,40 +254,55 @@ export class AclSandbox { this.sidAllocations.push(logonSid) const worldSid = makeWellKnownSid(api, abi.WinWorldSid) this.sidAllocations.push(worldSid) - const restricted = createRestrictedToken( - api, currentToken, logonSid, writeSidPtr, + const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined) + restrictedToken = createRestrictedToken( + api, currentToken, logonSid, writeSids, { world: worldSid }, this.mode, ) + this.token = restrictedToken // The restricted token's default DACL still names only the user's // ambient SIDs — none of the restricting SIDs. Every NEW object the // confined process creates (anonymous stdio pipes, sync objects) takes // its DACL from that default, so the write pass-2 check would deny // pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every // piped-stdio grandchild spawn. Merge a full-access ACE for a - // restricting SID (the write SID under workspace-write, Everyone under - // read-only): new-object creation stays gated by the parent object's - // DACL, while the new object's own DACL passes pass-2. - setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid) - this.token = restricted + // restricting SID (the PRIVATE temp SID when present, otherwise the + // workspace SID, or Everyone under read-only): new-object creation + // stays gated by the parent object's DACL, while the new object's own + // DACL passes pass-2. Choosing the temp SID prevents default-DACL + // objects in one session's temp tree from acquiring the shared + // workspace capability. + setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid) if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') + currentTokenOpen = false this.api = api } catch (error) { - // Best-effort close on the failure path (last error already captured in `error`). - api.closeHandle(currentToken) // Fail-closed cleanup: never leave a revocable (temp) grant or SID // allocation behind a failed init. Standing workspace ACEs are NOT // revoked — they are the intended end state (the reuse cache), not an // error artifact. const cleanupFailures: unknown[] = [] - const writeSidPtr = this.writeSidPtr - if (writeSidPtr !== undefined) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (cleanupError) { - cleanupFailures.push(cleanupError) - } + if (currentTokenOpen && api.closeHandle(currentToken) === 0) { + cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'current process token after init failure')) + } + if (restrictedToken !== undefined && api.closeHandle(restrictedToken) === 0) { + cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'restricted token after init failure')) + } + for (const grant of this.grantedPaths) { + try { + revokeWrite(api, grant.path, grant.sidPtr) + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } + for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { + if (sidPtr === undefined) continue + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label) + } catch (cleanupError) { + cleanupFailures.push(cleanupError) } } for (const sidPtr of this.sidAllocations.splice(0)) { @@ -272,10 +313,14 @@ export class AclSandbox { cleanupFailures.push(cleanupError) } } + this.token = undefined + this.writeSidPtr = undefined + this.tempWriteSidPtr = undefined + this.grantedPaths = [] if (cleanupFailures.length > 0) { throw new AggregateError( [error, ...cleanupFailures], - `AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`, + `AclSandbox init failed and ${cleanupFailures.length} cleanup operation(s) also failed`, ) } throw error @@ -341,20 +386,20 @@ export class AclSandbox { const api = this.api if (api === undefined) return const failures: unknown[] = [] - const writeSidPtr = this.writeSidPtr - if (writeSidPtr !== undefined) { - if (this.manageDacls) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (error) { - failures.push(error) - } + if (this.manageDacls) { + for (const grant of this.grantedPaths) { + try { + revokeWrite(api, grant.path, grant.sidPtr) + } catch (error) { + failures.push(error) } } + } + for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { + if (sidPtr === undefined) continue try { - const freed = api.localFree(writeSidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID') + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label) } catch (error) { failures.push(error) } @@ -378,6 +423,7 @@ export class AclSandbox { this.api = undefined this.token = undefined this.writeSidPtr = undefined + this.tempWriteSidPtr = undefined this.grantedPaths = [] if (failures.length > 0) { throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`) diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 819884c4ac..1a63a18545 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -10,33 +10,29 @@ * keep the same contract): * [node, runner.js, '--workspace', , '--temp', , * '--mode', , - * ['--write-sid', ], '--', ] + * ['--write-sid', , + * '--temp-write-sid', ], '--', ] * * Modes: - * - workspace-write: the workspace and temp directories carry the orphan-SID - * Write grant; other ACL-addressable writes are denied except for the - * documented Everyone and hard-link boundaries. + * - workspace-write: the workspace and temp directories carry distinct + * capability-SID Write grants; other ACL-addressable writes are denied + * except for the documented Everyone and hard-link boundaries. * - read-only: no orphan-SID grants; the restricting list carries no orphan * SID, so a standing grant ACE from an earlier * workspace-write period stays inert. BOTH modes drop Authenticated Users * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the * Public tree writes are denied); the two lists share the keep-alive group - * (logon SID, EVERYONE) and differ only by the orphan. + * (logon SID, EVERYONE) and differ only by the capabilities. * - * `--write-sid`: the seam's grant contract — the CALLER has already - * materialized the write-SID ACEs (the seam's workspace + private-temp - * grants, server lifetime) and owns their revocation, so the runner neither - * grants nor revokes (manageDacls: false). The carried SID is the - * per-workspace identity ({@link workspaceWriteSid}) — the seam derives it - * from the policy root; the flag's PRESENCE is the seam-managed marker (its - * value must equal the workspace-derived SID). Absent `--write-sid` - * (standalone/test use) the runner self-manages grants per invocation with - * the same workspace-derived SID (its workspace ACEs are standing — the - * reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in - * workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN - * environment (SetEnvironmentVariableW) to the `--temp` directory — a - * PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs - * /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment + * `--write-sid` + `--temp-write-sid`: the seam's grant contract — the + * CALLER has already materialized distinct workspace and private-temp ACEs + * and owns their revocation, so the runner neither grants nor revokes + * (`manageDacls: false`). Both values are checked against their owning paths. + * Without the pair (standalone/agentless use), workspace-write treats + * `--temp` as a ROOT, creates a random private child directory, derives its + * own temp SID, and removes that directory after the child exits. In both + * flows the runner rewrites TMP/TEMP in its OWN environment to the private + * directory before spawning; the child inherits that block (`lpEnvironment` * NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in * CreateProcessAsUserW, verified empirically). Read-only leaves the ambient * temp entries untouched (writes there are denied anyway). @@ -48,11 +44,12 @@ * @module @deepseek-ai/dsh-sandbox-windows-acl/runner */ -import { existsSync, statSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs' +import { join } from 'node:path' import { win32 } from './ffi.ts' import { AclSandbox } from './index.ts' -import { workspaceWriteSid } from './workspace-sid.ts' +import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' const RUNNER_FAILURE_EXIT = 127 @@ -70,6 +67,7 @@ interface ParsedArgs { temp: string mode: 'read-only' | 'workspace-write' writeSid: string | undefined + tempWriteSid: string | undefined command: string args: string[] } @@ -79,6 +77,7 @@ function parseArgs(raw: string[]): ParsedArgs { let temp: string | undefined let mode: string | undefined let writeSid: string | undefined + let parsedTempWriteSid: string | undefined let index = 0 for (; index < raw.length; index++) { const token = raw[index] @@ -94,6 +93,7 @@ function parseArgs(raw: string[]): ParsedArgs { case '--temp': temp = value; break case '--mode': mode = value; break case '--write-sid': writeSid = value; break + case '--temp-write-sid': parsedTempWriteSid = value; break default: fail(`unknown argument: ${token}`) } } @@ -103,7 +103,7 @@ function parseArgs(raw: string[]): ParsedArgs { const argv = raw.slice(index) const command = argv[0] if (command === undefined) fail('missing command after --') - return { workspace, temp, mode, writeSid, command, args: argv.slice(1) } + return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -119,6 +119,14 @@ async function main(): Promise { requireDirectory('--workspace', parsed.workspace) requireDirectory('--temp', parsed.temp) + const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined + if (parsed.mode === 'read-only' && seamManaged) { + fail('read-only does not accept --write-sid or --temp-write-sid') + } + if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) { + fail('workspace-write requires --write-sid and --temp-write-sid together') + } + const api = await win32() // Ignore this process's own CTRL+C: the confined child (same console) keeps // handling its own; the runner must survive to revoke grants and mirror the @@ -127,36 +135,46 @@ async function main(): Promise { fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) } - // The write SID is the per-workspace identity in BOTH flows; the flag's - // presence (seam-derived, or the self-managed derivation) selects who - // owns the DACLs below. - const writeSid = parsed.mode === 'workspace-write' ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : undefined - const sandbox = new AclSandbox({ - writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], - tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, - mode: parsed.mode, - ...writeSid === undefined ? {} : { writeSid }, - // With --write-sid the seam owns the DACLs (workspace + private-temp - // grants): this invocation must neither add nor revoke ACEs. - manageDacls: parsed.writeSid === undefined, - }) - await sandbox.init() - - // The seam's per-session temp contract: under --write-sid, workspace-write - // children see the PRIVATE per-session temp subdirectory through TMP/TEMP - // (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment - // (SetEnvironmentVariableW) and the child inherits the block; self-managed - // and read-only runs keep the ambient entries. - if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) { - if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) { - fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) - } - if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) { - fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) - } - } - + let ownedTempDir: string | undefined + let sandbox: AclSandbox | undefined + let initialized = false try { + let privateTempDir: string | null = null + let writeSid: string | undefined + let privateTempSid: string | undefined + if (parsed.mode === 'workspace-write') { + writeSid = workspaceWriteSid(parsed.workspace) + if (seamManaged) { + if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace') + privateTempDir = parsed.temp + privateTempSid = tempWriteSid(privateTempDir) + if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp') + } else { + ownedTempDir = mkdtempSync(join(parsed.temp, 'dsh-')) + privateTempDir = ownedTempDir + privateTempSid = tempWriteSid(privateTempDir) + } + } + sandbox = new AclSandbox({ + writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], + tempDir: privateTempDir, + mode: parsed.mode, + ...writeSid === undefined ? {} : { writeSid }, + ...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid }, + manageDacls: !seamManaged, + }) + await sandbox.init() + initialized = true + + if (privateTempDir !== null) { + if (api.setEnvironmentVariableW('TMP', privateTempDir) === 0) { + fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) + } + if (api.setEnvironmentVariableW('TEMP', privateTempDir) === 0) { + fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) + } + } + const child = sandbox.spawn({ command: parsed.command, args: parsed.args, @@ -166,10 +184,19 @@ async function main(): Promise { return result.exitCode } finally { // Cleanup failures must not mask the child's exit code: report and keep going. - try { - sandbox.dispose() - } catch (error) { - process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + if (initialized) { + try { + sandbox?.dispose() + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } + } + if (ownedTempDir !== undefined) { + try { + rmSync(ownedTempDir, { recursive: true, force: true }) + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } } } } diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 051afc740d..abd0c73617 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -162,11 +162,11 @@ export interface RestrictingSidSet { * Create the write-restricted token with the mode-selected restricting list * (verified on Win11 26200, see the POC-worktree restrict-variant harness): * - read-only: [logon SID, EVERYONE] - * - workspace-write: [logon SID, EVERYONE, orphan] + * - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID] * * The logon SID + EVERYONE keep-alive group is shared by both modes: early * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — - * pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY + * pwsh crashes 0xE0434352) fails without them. The write SIDs join ONLY * workspace-write — read-only carries no write SID, so a standing grant ACE * from an earlier workspace-write period (a `/permission` mode downgrade, or * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED @@ -186,24 +186,25 @@ export interface RestrictingSidSet { * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only). + * @param writeSids - the distinct write SIDs forming the workspace and + * optional temp allowlists (workspace-write only; empty under read-only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (workspace-write adds the write SID). + * @param mode - selects the restricting list (workspace-write adds the capability SIDs). * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, currentToken: NativePtr, logonSid: NativePtr, - writeSid: NativePtr | undefined, + writeSids: readonly NativePtr[], known: RestrictingSidSet, mode: 'read-only' | 'workspace-write', ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : writeSid === undefined - ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })() - : [logonSid, known.world, writeSid]) + : writeSids.length === 0 + ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires at least one write SID') })() + : [logonSid, known.world, ...writeSids]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts index db74893f36..46a64d5710 100644 --- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -8,8 +8,10 @@ * once per session. The SID's power is defined solely by the ACEs that name * it (which exist only on the workspace tree and the session's private temp * directory), and only tokens minted for that workspace carry it — the SID - * string itself is not a secret (the previous per-session SID was likewise - * logged in the plain). + * string itself is not a secret. Temporary directories use a separate, + * per-directory identity from {@link tempWriteSid}; sharing the workspace + * identity with temp would let sibling sessions write one another's temp + * trees. * * The input MUST be the canonical workspace path (`realpathSync.native` on * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it): @@ -36,3 +38,17 @@ export function workspaceWriteSid(workspaceRoot: string): string { const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 return `S-1-4-${first}-${second}` } + +/** + * Derive one private temp directory's write SID. The random directory path + * is the capability identity; a fixed third subauthority domain-separates + * the result from every two-subauthority workspace SID. + * @param tempDir - the private temp directory's absolute path. + * @returns the SDDL string form. + */ +export function tempWriteSid(tempDir: string): string { + const digest = createHash('sha256').update('temp\0', 'utf8').update(tempDir, 'utf8').digest() + const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1 + const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 + return `S-1-4-${first}-${second}-1` +} diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 25abe14392..729123b7d7 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -192,21 +192,38 @@ describe.skipIf(!isWin32)('ACL editing', () => { const api = await win32() const workspaceDir = scratch() const tempDir = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ + writableDirs: [workspaceDir], + tempDir, + writeSid: 'S-1-4-9000-3', + tempWriteSid: 'S-1-4-9000-3-1', + mode: 'workspace-write', + }) await sandbox.init() sandbox.dispose() const workspaceAces = readDirectAces(api, workspaceDir) expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) const tempAces = readDirectAces(api, tempDir) - expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false) + expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false) }) it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => { const dir = scratch() expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })) .toThrow(/requires a write SID/) - expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write')) - .toThrow(/requires the write SID/) + expect(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + .toThrow(/requires an explicit private temp directory or null/) + expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + .toThrow(/requires a temp write SID/) + expect(() => new AclSandbox({ + writableDirs: [dir], + tempDir: dir, + writeSid: 'S-1-4-1-1', + tempWriteSid: 'S-1-4-1-1', + mode: 'workspace-write', + })).toThrow(/must be distinct/) + expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write')) + .toThrow(/requires at least one write SID/) }) it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index 0825695438..8d20c4ab32 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -6,10 +6,10 @@ * WRITE_RESTRICTED token intersects write accesses only. * * The escape target sits in its own scratch dir under the system temp - * directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never - * defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the - * whole real temp tree) and the writable dir is a separate mkdtemp directory - * that contains neither sibling. Nothing under the user profile is touched. + * directory, OUTSIDE both granted trees: tempDir is an explicit private + * mkdtemp directory (the API never grants the ambient temp root implicitly), + * and the writable dir is a separate mkdtemp directory that contains neither + * sibling. Nothing under the user profile is touched. */ import { execFileSync } from 'node:child_process' @@ -47,11 +47,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') - // tempDir is passed explicitly: GetTempPathW reads the native environment - // block, which host runtimes (vitest worker pools) may not keep in sync - // with process.env — and a real-temp grant would inherit over every - // temp subdirectory, including this test's scratch dir. - sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) + // The direct API requires this explicit private temp directory and its + // own SID; it never widens the grant over the ambient temp root. + sandbox = new AclSandbox({ + writableDirs: [writableDir], + tempDir: isolatedTemp, + writeSid: 'S-1-4-9000-4', + tempWriteSid: 'S-1-4-9000-4-1', + mode: 'workspace-write', + }) await sandbox.init() }) @@ -91,7 +95,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { // A malformed SID makes ConvertStringSidToSidW fail; init must throw // before any grant is applied and never spawn unrestricted. - const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts index 0534bb00d7..3223a940d9 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -25,7 +25,7 @@ async function setup(internals: LocalSandboxProvider['internals']) { } describe('windows-acl win32 chain (LocalSandboxProvider)', () => { - it('workspace-write: runner argv prefix, explicit temp, mode flag, partial enforcement, ACL denial dialect', async () => { + it('agentless workspace-write: runner argv prefix, temp root, mode flag, partial enforcement, ACL denial dialect', async () => { const probeWindowsAcl = vi.fn(() => true) const sandbox = await setup({ platform: 'win32', diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 82ffe6606a..36b3ce17d2 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' -import { AclWriteGrant } from '../src/index.ts' +import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts' const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) @@ -77,12 +77,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('workspace-write: the confined child writes granted directories only', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", - // The restricted token puts pwsh into ConstrainedLanguage in BOTH modes - // (documented Known Limitation) — pinned here so a token change that - // silently restores FullLanguage is caught. + // The private-temp capability lets PowerShell complete its startup + // AppLocker probe, so without a host policy workspace-write stays in + // FullLanguage. Read-only cannot create those scratch files and fails + // that probe closed to ConstrainedLanguage (pinned below). '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + "try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, // Authenticated Users is absent from BOTH lists: the WMI namespace @@ -96,7 +97,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') + expect(result.stdout).toContain('LANGMODE: FullLanguage') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') @@ -163,33 +164,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(renamedDir)).toBe(true) }, 30_000) - it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => { - const writeSid = 'S-1-4-9000-99' + it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => { + const seamWorkspace = join(scratchRoot, 'seam-workspace') + mkdirSync(seamWorkspace) + const writeSid = workspaceWriteSid(seamWorkspace) const privateTemp = join(isolatedTemp, 'private-subdir') mkdirSync(privateTemp) - const grant = AclWriteGrant.create(writeSid) + const privateTempSid = tempWriteSid(privateTemp) + const grant = AclWriteGrant.create(privateTempSid) grant.add(privateTemp) try { const probe = [ "$ErrorActionPreference='SilentlyContinue';", - `try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, + `try{Set-Content -Path '${seamWorkspace}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, `try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`, "'TEMP-ENV: ' + $env:TEMP;", "'TMP-ENV: ' + $env:TMP", ].join('') const result = runRunner([ - '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--workspace', seamWorkspace, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--temp-write-sid', privateTempSid, '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) - // The runner granted nothing (only the caller's private-temp grant + // The runner granted nothing (only the caller's temp-SID grant // stands): the workspace write is denied, the private temp write lands, // and the child's TMP/TEMP point at the private subdirectory. expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED') expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK') expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`) expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`) - expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false) + expect(existsSync(join(seamWorkspace, 'server-granted.txt'))).toBe(false) expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true) } finally { grant.dispose() @@ -197,6 +202,81 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('temp capabilities isolate sibling sessions that share one workspace SID', () => { + const writeSid = workspaceWriteSid(writableDir) + const tempA = join(isolatedTemp, 'session-a') + const tempB = join(isolatedTemp, 'session-b') + mkdirSync(tempA) + mkdirSync(tempB) + const sidA = tempWriteSid(tempA) + const sidB = tempWriteSid(tempB) + const workspaceGrant = AclWriteGrant.create(writeSid) + const grantA = AclWriteGrant.create(sidA) + const grantB = AclWriteGrant.create(sidB) + workspaceGrant.add(writableDir) + grantA.add(tempA) + grantB.add(tempB) + const sharedWorkspaceFile = join(writableDir, 'shared-between-sessions.txt') + const probe = [ + "const fs = require('node:fs');", + "const targets = [['OWN', process.argv[1]], ['SIBLING', process.argv[2]], ['WORKSPACE', process.argv[3]]];", + "if (process.argv[4]) targets.push(['SIBLING-EXISTING', process.argv[4]]);", + 'for (const [name, target] of targets) {', + "try { fs.writeFileSync(target, name); console.log(name + ': OK'); } catch { console.log(name + ': DENIED'); }", + '}', + ].join('') + try { + const resultA = runRunner([ + '--workspace', writableDir, '--temp', tempA, '--mode', 'workspace-write', + '--write-sid', writeSid, '--temp-write-sid', sidA, + '--', process.execPath, '-e', probe, join(tempA, 'a.txt'), join(tempB, 'a-escaped.txt'), sharedWorkspaceFile, + ]) + expect(resultA.status, `stderr: ${resultA.stderr}`).toBe(0) + expect(resultA.stdout).toContain('OWN: OK') + expect(resultA.stdout).toContain('SIBLING: DENIED') + expect(resultA.stdout).toContain('WORKSPACE: OK') + + const resultB = runRunner([ + '--workspace', writableDir, '--temp', tempB, '--mode', 'workspace-write', + '--write-sid', writeSid, '--temp-write-sid', sidB, + '--', process.execPath, '-e', probe, join(tempB, 'b.txt'), join(tempA, 'b-escaped.txt'), sharedWorkspaceFile, join(tempA, 'a.txt'), + ]) + expect(resultB.status, `stderr: ${resultB.stderr}`).toBe(0) + expect(resultB.stdout).toContain('OWN: OK') + expect(resultB.stdout).toContain('SIBLING: DENIED') + expect(resultB.stdout).toContain('SIBLING-EXISTING: DENIED') + expect(resultB.stdout).toContain('WORKSPACE: OK') + expect(existsSync(join(tempB, 'a-escaped.txt'))).toBe(false) + expect(existsSync(join(tempA, 'b-escaped.txt'))).toBe(false) + expect(readFileSync(join(tempA, 'a.txt'), 'utf8')).toBe('OWN') + } finally { + workspaceGrant.dispose() + grantA.dispose() + grantB.dispose() + rmSync(tempA, { recursive: true, force: true }) + rmSync(tempB, { recursive: true, force: true }) + } + }, 30_000) + + it('agentless workspace-write creates a fresh private temp per call and removes it on exit', () => { + const captureA = join(writableDir, 'agentless-temp-a.txt') + const captureB = join(writableDir, 'agentless-temp-b.txt') + for (const capture of [captureA, captureB]) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], process.env.TEMP)", capture, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + } + const tempA = readFileSync(captureA, 'utf8') + const tempB = readFileSync(captureB, 'utf8') + expect(tempA).not.toBe(tempB) + expect(tempA.startsWith(isolatedTemp)).toBe(true) + expect(tempB.startsWith(isolatedTemp)).toBe(true) + expect(existsSync(tempA)).toBe(false) + expect(existsSync(tempB)).toBe(false) + }, 30_000) + it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => { // Two-layer pin of the grandchild-spawn boundary: // - the token default DACL carries a restricting-SID ACE (set in init), @@ -238,7 +318,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // it, so the workspace write is denied (previously it LEAKED). The // switch back reuses the SAME standing ACE: the re-upgrade write lands // without any re-grant. - const writeSid = 'S-1-4-9001-7' + const writeSid = workspaceWriteSid(writableDir) + const privateTemp = join(isolatedTemp, 'mode-switch-temp') + mkdirSync(privateTemp) + const privateTempSid = tempWriteSid(privateTemp) const grant = AclWriteGrant.create(writeSid) grant.add(writableDir) try { @@ -247,7 +330,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`, ].join('') const downgraded = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid, + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe, ]) expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0) @@ -259,7 +342,8 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`, ].join('') const reupgraded = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--temp-write-sid', privateTempSid, '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe, ]) expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0) @@ -267,6 +351,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true) } finally { grant.dispose() + rmSync(privateTemp, { recursive: true, force: true }) } }, 30_000) @@ -338,4 +423,23 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.status).toBe(127) expect(result.stderr).toContain('windows-acl-run: ') }, 15_000) + + it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => { + const writeSid = workspaceWriteSid(writableDir) + const tempSid = tempWriteSid(isolatedTemp) + const cases = [ + ['--write-sid', writeSid], + ['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid], + ['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'], + ] + for (const args of cases) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + ...args, + '--', process.execPath, '-e', 'process.exit(99)', + ]) + expect(result.status, `args: ${args.join(' ')}\nstderr: ${result.stderr}`).toBe(127) + expect(result.stderr).toContain('windows-acl-run: ') + } + }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts index 4d24c6f8fb..8581f39112 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest' -import { workspaceWriteSid } from '../src/index.ts' +import { tempWriteSid, workspaceWriteSid } from '../src/index.ts' describe('workspaceWriteSid', () => { it('derives a stable orphan-shaped SID per workspace path', () => { @@ -28,3 +28,16 @@ describe('workspaceWriteSid', () => { expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo')) }) }) + +describe('tempWriteSid', () => { + it('derives a stable domain-separated SID per private temp path', () => { + const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123') + expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) + expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u) + expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) + }) + + it('derives distinct capabilities for distinct private temp paths', () => { + expect(tempWriteSid('C:\\Temp\\dsh-a')).not.toBe(tempWriteSid('C:\\Temp\\dsh-b')) + }) +}) From 10ba0f8ad2827a8970ed03af9619402a2c57fe86 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:41:30 +0800 Subject: [PATCH 09/67] docs(windows): align sandbox ownership --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-06-sandbox.md | 2 +- .../implemented/feature/2026-07-06-sandbox.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 ++-- .../feature/2026-08-01-windows-pwsh-default.md | 16 ++++++++-------- .../2026-08-01-windows-pwsh-default.zh.md | 16 ++++++++-------- ...indows-acl-restricted-token-sandbox.i18n.yaml | 4 ++-- ...08-08-windows-acl-restricted-token-sandbox.md | 2 +- ...08-windows-acl-restricted-token-sandbox.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/bash/pwsh-sandbox/README.i18n.yaml | 4 ++-- packages/bash/pwsh-sandbox/README.md | 4 ++-- packages/bash/pwsh-sandbox/README.zh.md | 4 ++-- packages/sandbox/sandbox/src/index.ts | 10 +++++----- 16 files changed, 41 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 289e9a582a..85fdd2b82c 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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-06-sandbox.md -2026-07-06-sandbox.md: 0214a202a41983c76fa25e3a82e1cfaec70a0d55 -2026-07-06-sandbox.zh.md: bd8dcdb74f723a955ea2a1cc5b224ef2ded4a8d5 +2026-07-06-sandbox.md: e1ec35cdcf3be2af03232f823d9a9cee57b4e8e8 +2026-07-06-sandbox.zh.md: d3b74d441f72fc4c218979c327546031fd1b24f1 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 0214a202a4..e1ec35cdcf 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -171,7 +171,7 @@ Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). -- **Windows has no backend.** Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase. +- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its Everyone and hard-link gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index bd8dcdb74f..d3b74d441f 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -171,7 +171,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 -- **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 +- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 49b713f534..24219d9ff0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.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-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49 -2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4 +2026-08-01-windows-pwsh-default.md: 4e681b32088954d870df86898e26fe2cae669f14 +2026-08-01-windows-pwsh-default.zh.md: a9d600f8a8e47db49c3733f33091e667e341c6a7 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index f0da86e52b..4e681b3208 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,9 +12,9 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. -- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. -- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`. It disables the POSIX-only `bash-sandbox`/`tool-bash` rows and inserts `pwsh-sandbox`/`tool-pwsh`. The later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled the win32 runner chain and superseded this note's original unconfined roster: `sandbox`, `sandbox-policy`, `fs-sandbox`, `permission`/`ui-permission`, and `approval` now stay enabled exactly as on POSIX, while the ACL backend truthfully reports its Everyone and hard-link gaps as partial enforcement. +- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows and disables both pwsh rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`; the executor's dependency chain supplies `dsh-pwsh-local`, and the base bundle lists every row plugin as a dependency by house style. The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. @@ -24,21 +24,21 @@ The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches ba **Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate. -**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. +**Keep `permission`/`ui-permission` on Windows without a confining runner.** Rejected by the original delivery: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor. The later ACL runner removed that premise, so the current roster retains both rows. -**Keep fs path-rule confinement on Windows (`sandbox-policy` + `fs-sandbox` without OS runners).** Rejected: the shell is the model's primary tool and unconfined on Windows, so fs-only path rules are trivially bypassable and would overstate the boundary; the honest posture is full degradation to danger-full-access. +**Keep fs path-rule confinement on Windows without an OS runner.** Rejected by the original delivery: an unconfined shell could bypass fs-only path rules. The current ACL runner confines the shell and the fs provider under one policy, so this rejected half-boundary is no longer the shipped shape. **Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions. ## Consequences - A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). -- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. +- Windows commands and fs operations share the sandbox policy, permission switcher, and approval service. The ACL runner confines writes but reports `enforcement: 'partial'`; explicit `danger-full-access` remains the approved bypass rather than the platform default. - POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. -- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. +- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service). +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, custom-profile skip, missing-patch failure, cold-start dependency closure, and real composed roster; `packages/bundle/base/tests/base.spec.ts` pins that the Windows layer disables only the bash rows, inserts the confined pwsh rows, and leaves sandbox, permission, fs, and approval ownership untouched. - Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 41a6429eab..a9d600f8a8 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,9 +12,9 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 -- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 -- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml)。它禁用仅限 POSIX 的 `bash-sandbox`/`tool-bash` 行,并插入 `pwsh-sandbox`/`tool-pwsh`。后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)填充了 win32 runner 链,并取代了本笔记最初的不限权清单:`sandbox`、`sandbox-policy`、`fs-sandbox`、`permission`/`ui-permission` 与 `approval` 均与 POSIX 上一样保持启用,而 ACL 后端则如实把 Everyone 与硬链接缺口报告为部分强制执行。 +- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行,并禁用两个 pwsh 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`;执行器的依赖链提供 `dsh-pwsh-local`,按仓库惯例,base bundle 把每个行插件都列为依赖。 pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 @@ -24,21 +24,21 @@ pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-b **从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决:patch 应放在它替换的行旁边、属于拥有这些行的 bundle,让交付清单作为组合数据保持可见、转储带有出处;启动器只贡献 win32 门控。 -**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。 +**在 Windows 没有隔离 runner 时保留 `permission`/`ui-permission`。** 最初交付时否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,并在不限权执行器上加载时 fail loud。后续的 ACL runner 消除了该前提,因此当前清单保留这两行。 -**在 Windows 上保留 fs 路径规则限制(无 OS runner 的 `sandbox-policy` + `fs-sandbox`)。** 否决:shell 是模型的主工具且在 Windows 上不限权,仅限 fs 的路径规则一行命令即可绕过,会夸大边界;诚实的姿态是完全退化到 danger-full-access。 +**在 Windows 没有 OS runner 时保留 fs 路径规则限制。** 最初交付时否决:不限权 shell 可以绕过仅限 fs 的路径规则。当前 ACL runner 用同一策略约束 shell 与 fs 提供方,因此这项被否决的半边界已不是当前交付形态。 **交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。 ## 后果 - 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 -- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 +- Windows 命令与 fs 操作共用沙箱策略、权限切换器和 approval 服务。ACL runner 限制写入,但报告 `enforcement: 'partial'`;显式的 `danger-full-access` 仍是获准的绕过方式,而非平台默认。 - POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 -- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app)断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过、缺少 patch 时失败、冷启动依赖闭包和真实组合清单;`packages/bundle/base/tests/base.spec.ts` 固定 Windows 层仅禁用 bash 行、插入受限的 pwsh 行,并且不改变沙箱、权限、fs 与审批的归属。 - Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index aac04f71de..6d98659889 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.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-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 60db72042d7924b90423aac37f7360e317b991ca -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10b2c78accbd70610d8e0a29f76fedbb713ccc06 +2026-08-08-windows-acl-restricted-token-sandbox.md: 027a5ac7cbed455f75e78ac6ab3328dc3de7def4 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: a0824cb037f9de377c344e6a4ca76e020fae2416 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 60db72042d..027a5ac7cb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-windows-acl-restricted-token-sandbox.zh.md) ## Problem -The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degrade to danger-full-access because no confining executor exists. The win32 rung must confine the two file-effect modes the sandbox vocabulary promises — `read-only` (zero writes) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while leaving reads, network, and process visibility alone, because every mode permits reading. +The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degraded to danger-full-access because no confining executor existed. The win32 rung must govern the two file-effect modes in the sandbox vocabulary — `read-only` (no explicit writable root) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while reporting any effects its mechanism cannot govern; reads, network, and process visibility remain outside this vocabulary. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 10b2c78acc..a0824cb037 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[沙盒决策](2026-07-06-sandbox.md)把 `PLATFORM_CHAINS.win32` 留空,交付的 Windows profile 因为没有可用的隔离执行器而退化为 danger-full-access。win32 档必须实现沙盒词汇表承诺的两个文件效果模式——`read-only`(零写入)与 `workspace-write`(仅工作区根目录加后端定义的临时区域可写)——同时保持读、网络与进程可见性不受影响,因为所有模式都允许读取。 +最初的[沙箱决策](2026-07-06-sandbox.md)将 `PLATFORM_CHAINS.win32` 留空,因此交付的 Windows profile 因不存在隔离执行器而退化为 danger-full-access。win32 档必须约束沙箱词汇表中的两种文件效果模式——`read-only`(不显式授予任何可写根目录)与 `workspace-write`(允许写入工作区根目录及后端定义的临时区域)——并报告其机制无法约束的任何效果;读取、网络与进程可见性仍在这套词汇之外。 ## Decision diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 60c6e85cca..37df5888e6 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: 18980d22c694647374b9fa4e6dfbf245ff2416c4 -config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca +config-catalog.md: 8c666ec44435c4f7e3716150969c735ccb84b174 +config-catalog.zh.md: cd0251b4bc8e642390281e2b04d22b08e66994a6 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18980d22c6..8c666ec444 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1313,7 +1313,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a43c561806..cd0251b4bc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1315,7 +1315,7 @@ export interface Config { } ``` -来源:[`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) +来源:[`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml index f8100bf8a0..4bde957978 100644 --- a/packages/bash/pwsh-sandbox/README.i18n.yaml +++ b/packages/bash/pwsh-sandbox/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/bash/pwsh-sandbox/README.md -README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2 -README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec +README.md: c47409c122ffce264f53fc41c786da015fdaab6b +README.zh.md: 5b14185a71943d0a0a50f0bae353a3e22d0f1fb2 diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md index bd506d011f..c47409c122 100644 --- a/packages/bash/pwsh-sandbox/README.md +++ b/packages/bash/pwsh-sandbox/README.md @@ -30,5 +30,5 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work - **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. -- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap. -- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package). +- **Windows workspace-write temp authority is private** per live session/workspace pair; agentless calls receive a fresh private directory per invocation. The ambient temp root is never granted, and the runner rewrites TMP/TEMP to the private directory before spawning. +- **Windows read-only grants no explicit writable root but remains partial** because the restricted token must retain Everyone. Objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority; PowerShell's `> $null` redirection still works without opening NUL. diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md index e9aa380302..5b14185a71 100644 --- a/packages/bash/pwsh-sandbox/README.zh.md +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -30,5 +30,5 @@ ## 已知限制与后续工作 - **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 -- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。 -- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。 +- **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**;无 agent(智能体)的调用每次都获得一个新的私有目录。环境临时根目录绝不会被授权,runner 会在 spawn 前将 TMP/TEMP 重写为该私有目录。 +- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**,因为受限令牌必须保留 Everyone。DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源;PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。 diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 5fa3cc991b..c1281ce967 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -43,10 +43,10 @@ export interface SandboxExecutionPolicy { workspaceRoot: string /** * Opaque identity of the calling session (the branded `dsh-session` - * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session private temp subdirectory — the write grant itself is - * per-workspace, derived from the workspace root); absent for agentless - * calls, which fall back to per-call backend state. + * SessionId). Backends key per-session state off it (e.g. windows-acl gives + * each live session/workspace pair a random private temp directory and SID, + * while the workspace SID and standing grant remain per-workspace); absent + * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId } @@ -156,7 +156,7 @@ declare module 'cordis' { * skipped for a sole candidate, whose own refusal remains the fail-closed end. */ export abstract class SandboxProvider extends Service { - /* v8 ignore next -- Windows has no sandbox backend to instantiate this service. */ + /* v8 ignore next -- abstract service construction is covered through concrete provider packages. */ constructor(ctx: Context) { super(ctx, 'sandbox') } From be47fcec0863790b1abe37e224e5a05d2e47530c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:15:16 +0800 Subject: [PATCH 10/67] test(sandbox): cover workspace grant cleanup failure --- packages/sandbox/sandbox-local/tests/acl-grants.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index 3f6c3f2dc3..2de4af3a3d 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -215,7 +215,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('workspace grant failure disposes its SID and never creates a temp directory', async () => { + it('workspace grant failure disposes its SID, aggregates cleanup failure, and never creates a temp directory', async () => { try { const { sandbox } = await setup() const ws = workspaceRoot() @@ -227,6 +227,12 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { })).toThrow('workspace grant exploded') expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) + + mockState.disposeFailure = new Error('workspace cleanup exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'), + })).toThrow(/workspace grant failed and its cleanup also failed/u) + expect(mockState.grants).toHaveLength(2) } finally { cleanup() } From 1762d2b3d79582ba9d450c9a0516ca93f4aea28b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:29:11 +0800 Subject: [PATCH 11/67] refactor(sandbox): share best-effort SID cleanup --- .../sandbox/sandbox-windows-acl/src/index.ts | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 873c9feada..4675a7c886 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -130,6 +130,22 @@ export interface AclSandboxChild { wait(): Promise } +/** Free one optional SID while retaining a failure for best-effort sibling cleanup. */ +function freeSidBestEffort( + api: Win32Bindings, + sidPtr: NativePtr | undefined, + label: string, + failures: unknown[], +): void { + if (sidPtr === undefined) return + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label) + } catch (error) { + failures.push(error) + } +} + /** * One write-restricted sandbox instance: token + write-SID grants + spawn. * `init()` is fail-closed — any Win32 failure revokes the revocable (temp) @@ -297,21 +313,10 @@ export class AclSandbox { } } for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { - if (sidPtr === undefined) continue - try { - const freed = api.localFree(sidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label) - } catch (cleanupError) { - cleanupFailures.push(cleanupError) - } + freeSidBestEffort(api, sidPtr, label, cleanupFailures) } for (const sidPtr of this.sidAllocations.splice(0)) { - try { - const freed = api.localFree(sidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') - } catch (cleanupError) { - cleanupFailures.push(cleanupError) - } + freeSidBestEffort(api, sidPtr, 'init SID allocation', cleanupFailures) } this.token = undefined this.writeSidPtr = undefined @@ -396,13 +401,7 @@ export class AclSandbox { } } for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { - if (sidPtr === undefined) continue - try { - const freed = api.localFree(sidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label) - } catch (error) { - failures.push(error) - } + freeSidBestEffort(api, sidPtr, label, failures) } const token = this.token if (token !== undefined) { @@ -413,12 +412,7 @@ export class AclSandbox { } } for (const sidPtr of this.sidAllocations.splice(0)) { - try { - const freed = api.localFree(sidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') - } catch (error) { - failures.push(error) - } + freeSidBestEffort(api, sidPtr, 'init SID allocation', failures) } this.api = undefined this.token = undefined From e389900f7d9834b36fd2f6f7bd698798005289d2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 17:56:36 +0800 Subject: [PATCH 12/67] ci: run real-windows check on self-hosted runners Enable serial-windows to execute the complete check:ci on the in-house self-hosted Windows pool instead of the disabled hosted windows-2025 lane. Point the job at [self-hosted, dsh-win-ci, windows] and wire it into all-checks-passed so it gates pull requests. The Wine-emulated windows job remains the required win32 signal for PRs that cannot reach the self-hosted pool; this lane adds a real native-Windows run where one is available. --- .github/workflows/ci.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eebdafaa48..26a3d5be38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -622,9 +622,13 @@ jobs: run: pnpm run check:ci serial-windows: - if: false + # Real-Windows signal on the in-house self-hosted Windows pool. This lane + # runs the complete primary Node CI on actual Windows (in contrast to the + # Wine-emulated `windows` job), so win32-native output is exercised where + # it matters. Selector matches our self-hosted runners by label. + if: github.event_name == 'pull_request' name: serial / windows - runs-on: windows-2025 + runs-on: [self-hosted, dsh-win-ci, windows] steps: - uses: actions/checkout@v6 @@ -885,7 +889,7 @@ jobs: && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} - needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows] + needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows, serial-windows] if: always() && github.event_name == 'pull_request' steps: - name: Fail if any needed job did not succeed From 80b7f929ea1cd82f68e47e47996bb6042cd1876f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:17 +0800 Subject: [PATCH 13/67] feat(session-persistence): expose readRaw for per-session artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistence contract gains a concrete readRaw default (undefined for backends without a per-session artifact) and the JSONL backend overrides it with the decode of its physical zstd frames, so a consumer can read the stored artifact text verbatim — the session-log export depends on it. --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 17 +++++- docs/subsystems/persistence.zh.md | 17 +++++- .../tool-cordis/src/api-catalog.ts | 8 +++ .../session-persistence-jsonl/src/index.ts | 58 ++++++++++++++++++- .../tests/jsonl.spec.ts | 20 +++++++ .../tests/zstd.spec.ts | 21 +++++++ .../session/session-persistence/src/index.ts | 28 +++++++++ scripts/gen-cordis-catalog.ts | 1 + 11 files changed, 171 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..e870a59292 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: 51c6ae46eeca1279390c9d9315a6161edd2de618 +config-catalog.md: 0d1d2ddde31a7ea806ec273007d7b5743a553e3b config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..0d1d2ddde3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1395,7 +1395,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:59`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 65925a1608..fa128a80d0 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.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/persistence.md -persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 -persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 +persistence.md: 25541139de02d2bd3ea743fe530e47a628002695 +persistence.zh.md: d76ff93f5564e7612fd7107cd74e3138c03542d8 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 0266d17393..25541139de 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -237,6 +237,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -342,5 +357,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index ced8344016..d76ff93f55 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -237,6 +237,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -342,5 +357,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 9e63aa3ea3..5f4d9a124b 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -688,6 +688,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract locate(meta: SessionHeader): SessionLocation | undefined', jsDoc: '/**\n * Resolve this backend\'s independent local artifact for a session without\n * reading, creating, flushing, or otherwise materializing it. Backends such\n * as SQLite that do not own one artifact per session return `undefined`.\n * @param meta - the immutable session header whose artifact is requested.\n * @returns the backend-specific absolute location, when one exists.\n */', }, + { + signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */', + }, { signature: 'abstract create(meta: SessionHeader): Promise', jsDoc: '/**\n * Register a new session\'s metadata. A backend MAY defer the physical write\n * until the first {@link append} (lazy materialization), in which case a\n * created-but-never-appended session is absent from {@link list}\n * — abandoned sessions leave nothing behind.\n * @param meta - the immutable header (id, version, cwd, lineage) to record.\n */', @@ -2739,6 +2743,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionMap', declaration: 'export interface SessionProjectionMap {\n}', }, + { + name: 'SessionRawArtifact', + declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 8d4aad8e7a..b339f2d736 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -18,7 +18,8 @@ import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { @@ -233,6 +234,61 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } + /** + * Read a session's stored artifact text verbatim: the durable file bytes + * decoded from this backend's physical encoding (complete zstd frames + * concatenated, or UTF-8 plaintext). The content is the exact JSONL text the + * backend wrote — never a reconstruction from parsed events — so packed- + * chunk rows, key order, and line breaks survive byte-for-byte. A torn + * final frame is omitted, matching the committed-prefix semantics of every + * other read. + * @param id - the persisted session to read. + * @param signal - optional cancellation for the stat/read/decode work. + * @returns the raw artifact text plus the header parsed from its own first + * line, or `undefined` when the session has no stored artifact. + */ + override async readRaw(id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureRootEncoding() + signal?.throwIfAborted() + const path = await this.findLog(id, signal) + if (path === undefined) return undefined + let buffer: Buffer + // Revision-stable read: a writer appending between stat and readFile + // would yield a torn physical file (see readPrefix). + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + buffer = await readFile(path, { signal }) + signal?.throwIfAborted() + const after = fileRevision(await stat(path, { bigint: true })) + if (before === after) break + } + let content: string + if (this.compression === 'zstd') { + const { frames } = scanZstdFrames(buffer) + if (frames.length === 0) return undefined + const decoder = createZstdFrameDecoder() + const plaintexts: Buffer[] = [] + // The decoder yields views into a reused buffer; copy each frame's + // plaintext immediately so a later concat cannot read overwritten memory. + for (const plaintext of decoder.decode(buffer, frames)) { + signal?.throwIfAborted() + plaintexts.push(Buffer.from(plaintext)) + } + content = Buffer.concat(plaintexts).toString('utf8') + } else { + content = buffer.toString('utf8') + } + const meta = parseHeaderMeta(content.split('\n', 1)[0] as string) + if (meta === undefined || meta.id !== id) { + throw new Error(`corrupt session log: invalid header line in "${path}"`) + } + // The logical artifact name is `session.jsonl` regardless of the physical + // encoding suffix (`.jsonl.zstd` marks compression only). + return { meta, filename: 'session.jsonl', content } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index c7b4ab8841..390d0e9048 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -217,6 +217,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) }) + it('readRaw returns the stored artifact text verbatim with its original filename', async () => { + const m = meta('raw-read', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(m.id) + // Byte-identical to the physical file — never a reconstruction. + expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8')) + expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m))) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + + it('readRaw is undefined for an absent session', async () => { + const m = meta('raw-missing', '/work') + expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() + }) + it('keeps the same location on resume and gives a fork its own location', async () => { const parent = meta('location-parent', '/work') const parentLocation = ctx.sessionPersistence.locate(parent) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b459fcac43..37d3e6c13b 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -356,6 +356,27 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) }) + it('readRaw decodes the compressed artifact back to the original JSONL text', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-read-zstd', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + + const raw = await ctx.sessionPersistence.readRaw(header.id) + expect(raw).toBeDefined() + // The logical name drops the physical encoding suffix. + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(header.id) + expect(raw!.content).toBe([ + JSON.stringify(toHeaderLine(header)), + ...oneTurnLog().map(e => JSON.stringify(e)), + '', + ].join('\n')) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 5c3df73b30..0889a60edf 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -30,6 +30,16 @@ export interface SessionInspection { readonly events: readonly SessionEvent[] } +/** A backend's own raw artifact text for one session, verbatim. */ +export interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} + // The backend-agnostic write-path orchestration first-party backends compose. export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, @@ -83,6 +93,24 @@ export abstract class SessionPersistence extends Service { */ abstract locate(meta: SessionHeader): SessionLocation | undefined + /** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ + readRaw(_id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + return Promise.resolve(undefined) + } + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 885e0cf9d2..05aa92ac87 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -301,6 +301,7 @@ export const LINK_MAP: Readonly> = { SessionLocation: 'persistence.md', SessionPreparation: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', + SessionRawArtifact: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', SandboxMode: 'sandbox.md', From ded90bffbadb955cacaf89684135a991699c7a5f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:24 +0800 Subject: [PATCH 14/67] feat(apiproxy): host session-log download surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streams one ZIP of the root session artifact plus each subagent descendant verbatim (the persistence readRaw bytes) from GET /api/session.export as a host-only download — no wire envelope, absent from IApiClient. The downloads domain owns the query schema, the fetch handler answers the GET alongside the SSE routes, and compression runs on the host with fflate's streaming Zip API. --- THIRD_PARTY_NOTICES.md | 1 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 39 ++++ .../host/apiproxy/src/api/downloads.schema.ts | 22 +++ packages/host/apiproxy/src/api/downloads.ts | 25 +++ packages/host/apiproxy/src/api/index.ts | 9 +- packages/host/apiproxy/src/fetch/handler.ts | 12 ++ packages/host/apiproxy/src/index.ts | 2 + packages/host/apiproxy/src/session-export.ts | 176 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 1 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 + .../apiproxy/tests/session-export.spec.ts | 149 +++++++++++++++ pnpm-lock.yaml | 11 ++ 16 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 packages/host/apiproxy/src/api/downloads.schema.ts create mode 100644 packages/host/apiproxy/src/api/downloads.ts create mode 100644 packages/host/apiproxy/src/session-export.ts create mode 100644 packages/host/apiproxy/tests/session-export.spec.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fec5de6128..3da3747c24 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -59,6 +59,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index c30ee60ec2..6a7abf8bdc 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 64f6ae7bcd92735f821c8f8d2b3b93203dbac17e -README.zh.md: 680fcee730674a21b5c2407247ce46b1a01cf6f3 +README.md: 452e157ed93ca4ace07a8c958bad04b15d598b31 +README.zh.md: f0509276ef690874e788a3a1e312d35ec37034f0 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 64f6ae7bcd..452e157ed9 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,6 +28,8 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never materializes the whole archive. It requires both the persistence and session-query services: a deployment without either answers 500, a missing root session 404, and a descendant without a stored artifact fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. + Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. `session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 680fcee730..f0509276ef 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,6 +28,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进内存。它要求同时挂载持久化与 session-query 服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 + 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 `session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 42f69c6211..31c907fb43 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", + "fflate": "^0.8.2", "schemastery": "^3.18.0", "zod": "^4.4.3" }, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5576f2e75c..6301b9f04c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,6 +41,12 @@ import type { QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' +import { + sessionLogExportDeps, + sessionLogZipFilename, + streamSessionLogZip, +} from './session-export.ts' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, @@ -3348,6 +3354,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + downloads: { + async sessionLog(request, signal) { + // Clean error path first: missing services answer 500 and a missing + // root artifact 404 before any zip byte is produced. The root content + // read here is reused as the first zip entry, so nothing is read twice. + const deps = sessionLogExportDeps(ctx) + if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined) { + return new Response( + 'session log export is unavailable: missing session-query or session-persistence service', + { status: 500 }, + ) + } + let root: SessionRawArtifact | undefined + try { + root = await deps.sessionPersistence.readRaw(request.sessionId) + } catch (error: unknown) { + return new Response(String(error), { status: 500 }) + } + if (root === undefined) { + return new Response('session not found', { status: 404 }) + } + return new Response( + streamSessionLogZip(deps, root, request.sessionId, request.includeDescendants === true, signal), + { + headers: { + 'content-type': 'application/zip', + 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`, + }, + }, + ) + }, + }, + respond(message: ClientResponse): Promise { // Route by the echoed rpcId (the wire correlation): approvals first, // then questions — the two registries share one id space of UUIDs. diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts new file mode 100644 index 0000000000..d324711b78 --- /dev/null +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -0,0 +1,22 @@ +/** + * downloads domain zod schemas. The GET download surface has no wire + * envelope: the request arrives as query parameters (all strings), so its + * request schema parses the raw query-parameter object into the method's + * exact request shape. SessionId brand cast point: sessionIdSchema, and only + * there (hosted in sessions.schema like every other cast). + */ + +import { z } from 'zod' +import type { DownloadsApi } from './downloads.ts' +import { sessionIdSchema } from './sessions.schema.ts' + +/** session.export query params → the sessionLog request. */ +export const sessionLogQuerySchema = z + .object({ + sessionId: sessionIdSchema, + includeDescendants: z.string().optional(), + }) + .transform(query => ({ + sessionId: query.sessionId, + ...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}), + })) satisfies z.ZodType[0]> diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts new file mode 100644 index 0000000000..d0e6138b6a --- /dev/null +++ b/packages/host/apiproxy/src/api/downloads.ts @@ -0,0 +1,25 @@ +/** + * downloads domain contract: host-only download surfaces — the GET-download + * channel family, the mirror of the SSE-stream `events` domain. No wire + * envelope: the carrier's GET routes answer these directly, and the browser + * `IApiClient` never exposes them. + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Host-only download surfaces (no wire envelope; absent from IApiClient). */ +export interface DownloadsApi { + /** + * Stream one session-log ZIP — the root artifact verbatim plus each subagent + * descendant's — as an attachment response. The carrier's GET route answers + * this directly; the browser never calls it. + * @param request - the root session id and whether to include descendants. + * @param signal - cancellation for the underlying reads. + * @returns the ZIP attachment response; missing services answer 500 and a + * missing root session 404 before any byte is produced. + */ + sessionLog( + request: { sessionId: SessionId; includeDescendants?: boolean }, + signal: AbortSignal, + ): Promise +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index e8eb3f3272..e41afafa9c 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -16,6 +16,7 @@ import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' +import type { DownloadsApi } from './downloads.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ @@ -32,6 +33,8 @@ export interface ApiProxy { settings: SettingsApi credentials: CredentialsApi llm: LlmApi + /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ + downloads: DownloadsApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise } @@ -39,9 +42,8 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock, - SessionSearchItem, - SessionsApi, SessionSummary, + ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, + SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { @@ -57,6 +59,7 @@ export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' +export type { DownloadsApi } from './downloads.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index f62a3584e7..1e902f059e 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import type { z } from 'zod' import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts' +import { sessionLogQuerySchema } from '../api/downloads.schema.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' @@ -249,12 +250,23 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { const url = new URL(req.url) const path = url.pathname + // No-envelope GET channel surface (SSE streams + host-only download): + // physical routes that answer directly, without a wire envelope. if (path === '/api/events.mux' && req.method === 'GET') { return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } if (path === '/api/events.host' && req.method === 'GET') { return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } + if (path === '/api/session.export' && req.method === 'GET') { + // Query params are a different boundary from the POST envelope, but + // the request still casts its brands only through the domain schema. + const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)) + if (!parsed.success) { + return new Response('missing or invalid sessionId query parameter', { status: 400 }) + } + return api.downloads.sessionLog(parsed.data, req.signal) + } if (req.method !== 'POST' || !path.startsWith('/api/')) { return new Response('not found', { status: 404 }) diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 43ce9e2df4..06a7475dca 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -72,6 +72,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] + readonly downloads: ApiProxy['downloads'] readonly respond: ApiProxy['respond'] constructor(ctx: Context, config: Config) { @@ -94,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.credentials = api.credentials this.llm = api.llm this.events = api.events + this.downloads = api.downloads // createApiProxy returns closures (no `this` capture), so the bind is // behavior-neutral. this.respond = api.respond.bind(api) diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts new file mode 100644 index 0000000000..62d4ffcd05 --- /dev/null +++ b/packages/host/apiproxy/src/session-export.ts @@ -0,0 +1,176 @@ +/** + * Host-side session-log download: streams one ZIP archive whose files are the + * sessions' stored artifact text verbatim. The root artifact sits under its + * original base name (`session.jsonl`); each subagent descendant under + * `subagents//`. No manifest is written — every file is + * byte-identical to the backend's durable artifact and self-describing + * through its own header line. Compression happens on the host with fflate's + * streaming Zip API, so the response is chunked as it is produced and the + * host never materializes the whole archive. + * @module + */ + +import { Zip, ZipDeflate } from 'fflate' +import type { Context } from 'cordis' +import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' + +/** The services a session-log export needs (absent → the export is unavailable). */ +export interface SessionLogExportDeps { + readonly sessionQuery: SessionQueryService | undefined + readonly sessionPersistence: SessionPersistence | undefined +} + +/** + * Resolve the persistence and session-query services a log export needs. + * @param ctx - the composed host context. + * @returns the export services (absent when the deployment does not mount them). + */ +export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { + return { + sessionQuery: ctx.get('sessionQuery'), + sessionPersistence: ctx.get('sessionPersistence'), + } +} +/** One exported artifact: the stored text plus the zip path it lands at. */ +export interface SessionLogZipEntry { + /** Zip entry path (root filename verbatim; descendants under `subagents//`). */ + readonly path: string + /** The stored artifact text verbatim. */ + readonly content: string +} + +/** + * One safe zip path segment from an untrusted session id. Session ids are + * host-controlled, but the brand allows any non-empty string, so `../` and + * separator characters are neutralized before they can shape archive entries. + * @param id - the raw session id. + * @returns a filesystem-safe single path segment. + */ +function safeSessionIdSegment(id: string): string { + return id.replace(/[^A-Za-z0-9._-]/g, '_') +} + +/** + * The export archive filename for one root session. + * @param sessionId - the root session id (sanitized to one safe path segment). + * @returns the attachment filename for the session's export archive. + */ +export function sessionLogZipFilename(sessionId: string): string { + return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` +} +/** + * Yield the export entries in zip order: the preloaded root artifact first, + * then every subagent descendant in lineage order, each read from the + * persistence backend right before it is yielded and dropped after the + * consumer moves on (the host holds at most one descendant's artifact text at + * a time beyond the root). + * @param deps - the export services. + * @param root - the already-read root artifact (read by the caller so the + * missing-session path can answer cleanly before streaming starts). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param signal - optional cancellation for read work. + * @returns the export entries in zip order. + */ +export async function* sessionLogZipEntries( + deps: SessionLogExportDeps, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): AsyncGenerator { + yield { path: root.filename, content: root.content } + if (!includeDescendants) return + const sessionQuery = deps.sessionQuery + const sessionPersistence = deps.sessionPersistence + if (sessionQuery === undefined || sessionPersistence === undefined) { + // The caller validated services before the stream started; this arm is + // unreachable today and guards a future caller that skips the check. + throw new Error('session log export is unavailable: missing session-query or session-persistence service') + } + const seen = new Set([sessionId]) + const collect = async function* ( + nodes: readonly SessionLineageNode[], + ): AsyncGenerator { + for (const node of nodes) { + signal?.throwIfAborted() + const id = node.session.header.id + if (seen.has(id)) continue + seen.add(id) + const raw = await sessionPersistence.readRaw(id) + if (raw === undefined) { + throw new Error(`subagent "${id}" has no stored log artifact`) + } + yield { + path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, + content: raw.content, + } + yield* collect(node.descendants) + } + } + const lineage = await sessionQuery.traceSession(sessionId) + yield* collect(lineage.descendants) +} + +/** How many code points of artifact text one zip push carries (bounded encode memory). */ +const PUSH_CHUNK_CODE_POINTS = 1 << 16 + +/** + * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is + * read and validated by the caller before this is called (missing root or + * missing services answer cleanly before any byte is produced); each entry is + * then encoded and deflated in bounded chunks as it is produced, so the + * archive bytes arrive incrementally. A descendant that fails to read errors + * the stream (fail-loud, never silent under-export). + * @param deps - the export services. + * @param root - the already-read root artifact (first zip entry). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param signal - optional cancellation for read work. + * @returns the zip byte stream. + */ +export function streamSessionLogZip( + deps: SessionLogExportDeps, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + // fflate invokes the callback synchronously per compressed chunk; + // enqueued bytes stay bounded by the compressed archive size (the body + // consumer drains them over the wire as the stream is pulled). + const zip = new Zip((error, data, final) => { + if (error) { + controller.error(error) + return + } + if (data.byteLength > 0) controller.enqueue(data) + if (final) controller.close() + }) + void (async () => { + try { + for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) { + const deflate = new ZipDeflate(entry.path, { level: 6 }) + zip.add(deflate) + const content = entry.content + for (let offset = 0; offset < content.length; offset += PUSH_CHUNK_CODE_POINTS) { + signal?.throwIfAborted() + const finalChunk = offset + PUSH_CHUNK_CODE_POINTS >= content.length + deflate.push(encoder.encode(content.slice(offset, offset + PUSH_CHUNK_CODE_POINTS)), finalChunk) + } + } + zip.end() + } catch (error) { + // A mid-stream failure (missing descendant, cancellation, read + // error) must fail the download rather than ship a truncated archive. + controller.error(error instanceof Error ? error : new Error(String(error))) + } + })() + }, + }) +} diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2b9249b7f0..9365006de4 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -133,6 +133,7 @@ function scriptedApi(overrides: { }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), + downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 63c40414d8..ed286334a8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -300,6 +300,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, + downloads: { + async sessionLog() { + return new Response('stub', { status: 404 }) + }, + }, } } diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts new file mode 100644 index 0000000000..479e2ac178 --- /dev/null +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -0,0 +1,149 @@ +/** + * session.export host path: the GET download endpoint streams a ZIP whose + * files are the stored artifacts verbatim (root + optional descendants), and + * the degenerate compositions fail loudly (missing services → 500, missing + * root → 404, missing descendant → errored stream). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { unzipSync, strFromU8 } from 'fflate' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (id: string): SessionId => id as SessionId + +function header(id: string, parentSession?: SessionId): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 1000, + cwd: '/proj', + ...parentSession === undefined ? {} : { parentSession }, + delegationDepth: parentSession === undefined ? 0 : 1, + } +} + +function artifact(id: string, parentSession?: SessionId): SessionRawArtifact { + return { + meta: header(id, parentSession), + filename: 'session.jsonl', + content: `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, + } +} + +function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { + return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants } +} + +async function buildApi( + artifacts: Record, + descendants: SessionLineageNode[] = [], + services: { query?: boolean; persistence?: boolean } = { query: true, persistence: true }, +) { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + if (services.query) { + ctx.provide('sessionQuery', { + traceSession: async () => ({ + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants, + }), + } as never) + } + if (services.persistence) { + ctx.provide('sessionPersistence', { + readRaw: async (id: SessionId) => artifacts[id], + } as never) + } + return createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) +} + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('session.export download endpoint', () => { + it('streams a ZIP with the root artifact verbatim under its original filename', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) + }) + + it('includes descendant artifacts under subagents// when requested', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'grandchild-a': artifact('grandchild-a', sid('child-a')), + }, [ + node('child-a', node('grandchild-a')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/grandchild-a/session.jsonl', + ]) + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) + .toBe(artifact('child-a').content) + }) + + it('answers 404 for a missing root session', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(404) + }) + + it('answers 400 when the sessionId query parameter is absent', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?includeDescendants=true'), + ) + expect(response.status).toBe(400) + }) + + it('answers 500 when the deployment mounts no persistence or session-query service', async () => { + const api = await buildApi({}, [], { query: false, persistence: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('session-query') + }) + + it('fails the whole export when a descendant has no stored artifact', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + }, [node('child-missing')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + // The stream errors before completing, so the body read rejects rather + // than returning a truncated-but-valid archive. + await expect(response.arrayBuffer()).rejects.toThrow() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4912b264d7..a69b80cee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2727,6 +2727,9 @@ importers: specifier: ^9.0.0 version: 9.0.0 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -4292,6 +4295,9 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace + fflate: + specifier: ^0.8.2 + version: 0.8.3 schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery @@ -11521,6 +11527,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -16818,6 +16827,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 From 9574a99f45d6a2981e215b5a1d8a66f58312a825 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:49 +0800 Subject: [PATCH 15/67] feat(web): export the session log from the trajectory toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 导出 button fetches GET /api/session.export and saves the ZIP (root artifact plus subagent descendants). The plugin exposes exportLog through the view's inject face, resolves the tab label through the locale service, and disables the button while in flight; fixture mode answers 404 so the error bar explains the gap. --- .../navigation-panes/trajectory.expected.md | 1 + .../client/connection/src/client/fixture.ts | 6 ++ .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- packages/client/ui-trajectory/package.json | 3 + .../src/client/TrajectoryToolbar.module.css | 40 ++++++++++ .../src/client/TrajectoryToolbar.tsx | 23 ++++++ .../src/client/TrajectoryView.tsx | 27 ++++++- .../ui-trajectory/src/client/export-log.ts | 40 ++++++++++ .../client/ui-trajectory/src/client/index.ts | 33 +++++++- .../ui-trajectory/src/client/locales.ts | 24 ++++++ .../ui-trajectory/src/client/views.module.css | 12 +++ .../ui-trajectory/tests/client-bundle.spec.ts | 7 +- .../ui-trajectory/tests/export-log.spec.ts | 20 +++++ .../ui-trajectory/tests/toolbar.spec.tsx | 55 +++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 77 +++++++++++++++++++ packages/client/ui-trajectory/tsconfig.json | 3 + 18 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/export-log.ts create mode 100644 packages/client/ui-trajectory/src/client/locales.ts create mode 100644 packages/client/ui-trajectory/tests/export-log.spec.ts create mode 100644 packages/client/ui-trajectory/tests/toolbar.spec.tsx diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index a9b5dbb982..8e6fa01d68 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -2,6 +2,7 @@ - button "Use actual duration": Duration - button "Collapse turns": Turns - button "Collapse calls": Calls + - button "导出会话日志": 导出 - img - searchbox "Search trajectory" - region "Trajectory timeline": diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f149e28984..503cc8cc6b 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2834,6 +2834,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) return Promise.resolve({ accepted: true }) }, + // The host-only streaming download has no in-memory counterpart: fixture + // mode answers 404 so the export button's error bar explains the gap + // instead of hanging. + downloads: { + sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), + }, } const rpc: ClientConnectionRpc = { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 7fecd92d74..1caa4f316c 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: 5b8c0cd111c272007212fea0d2435c5fab2360ab -README.zh.md: 9aaa02ccd9d50b0f0b23e9a53ea9b1048d0e513f +README.md: fe63c58b54a23a3faa4180e11ceec1d6409c8160 +README.zh.md: ed379fbe133ce27ea3f70c16a647fdd294ba6ca8 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 5b8c0cd111..fe63c58b54 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The toolbar's 导出 button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents//session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact). Fixture mode (no host) answers 404 for the export. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 9aaa02ccd9..ed379fbe13 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。工具栏的「导出」按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`;无清单,与后端持久化工件逐字节一致)。fixture 模式(无宿主)对导出应答 404。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index f56a9825ce..4ec9721691 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -39,6 +40,7 @@ "diff": "^9.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -47,6 +49,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index aa38650411..6e213604cf 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -164,6 +164,46 @@ font: 14px/14px var(--ds-font-family-code); } +.export { + display: inline-flex; + flex: none; + align-items: center; + height: 20px; + padding: 0 7px; + gap: 4px; + border: 0; + border-radius: 3px; + color: var(--dsw-alias-label-tertiary); + background: transparent; + cursor: pointer; + font: var(--dsw-font-xxs-12); +} + +.export:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.export:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 1px; +} + +.export:disabled { + color: var(--dsw-alias-label-dimmed); + cursor: wait; +} + +.exportIcon { + flex: none; + width: 12px; + height: 12px; + stroke: currentColor; + stroke-width: 1.25; + stroke-linecap: round; + stroke-linejoin: round; +} + .search { display: flex; flex: 0 1 164px; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index 5db9f56e80..fd05582b4b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -24,6 +24,12 @@ export interface TrajectoryToolbarProps { searchQuery: string /** Update the live ledger search query. */ onSearchQueryChange: (query: string) => void + /** Whether the session-log export is in flight. */ + exporting: boolean + /** Trigger the session-log export download. */ + onExport: () => void + /** Export failure message, shown while set; null while idle or successful. */ + exportError: string | null } /** @@ -42,6 +48,9 @@ export function TrajectoryToolbar({ onToggleAllAssistants, searchQuery, onSearchQueryChange, + exporting, + onExport, + exportError, }: TrajectoryToolbarProps) { return (
@@ -105,6 +114,20 @@ export function TrajectoryToolbar({ Calls +
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 6d77f907f1..9d182b379b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -73,6 +73,8 @@ export interface TrajectoryViewInjected { loadHistoryTail: (signal: AbortSignal) => Promise loadOlderHistory: (signal: AbortSignal) => Promise setActualDuration: (actualDuration: boolean) => void + /** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */ + exportLog: () => Promise } interface UsageLike { @@ -184,7 +186,7 @@ function mergeSearchMatches( } export function TrajectoryView({ - useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, + useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog, inspect, onInspectDone, }: ConvViewProps & InjectFace) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) @@ -197,6 +199,8 @@ export function TrajectoryView({ const actualDuration = useDuration(value => value) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const [exporting, setExporting] = useState(false) + const [exportError, setExportError] = useState(null) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState(null) const [timelineRecordSelection, setTimelineRecordSelection] = useState<{ readonly index: number @@ -524,6 +528,19 @@ export function TrajectoryView({ : Promise.resolve(false) }, [loadOlderHistory]) + const onExport = useCallback(() => { + if (exporting) return + setExporting(true) + setExportError(null) + void exportLog().then( + () => { setExporting(false) }, + (error: unknown) => { + setExportError(error instanceof Error ? error.message : String(error)) + setExporting(false) + }, + ) + }, [exportLog, exporting]) + return (
+ {exportError !== null && ( +
+ {exportError} +
+ )} , filename: string, type: string): void { + const url = URL.createObjectURL(new Blob([bytes], { type })) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index a6b5a4282d..adfb20fb02 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -4,14 +4,18 @@ */ import type { Context } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the 'conversation.view' SlotMap row (declared by the slot's // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' +import { en, NS, zh } from './locales.ts' +import { downloadBytes, sessionLogZipFilename } from './export-log.ts' -/** Required services: the conversation view slot and independent history source. */ -export const inject = ['slots', 'sessionHistory'] +/** Required services: the conversation view slot, the independent history source, and the locale service. */ +export const inject = ['slots', 'sessionHistory', 'locale'] /** * Client plugin body: register the trajectory view tab. The registration @@ -19,12 +23,17 @@ export const inject = ['slots', 'sessionHistory'] * @param ctx - client root context. */ export function apply(ctx: Context): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries') + // Registration-time text (the view tab label) reads through the bound + // translate as a thunk, so it follows the active locale without + // re-registration. + const t = ctx.locale.bind(NS) const duration = createTrajectoryDurationStore() ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', order: 10, - label: 'Trajectory', + label: () => t('view.trajectory'), inject: (sessionId: SessionId): TrajectoryViewInjected => { const history = ctx.sessionHistory.source(sessionId) return { @@ -32,6 +41,24 @@ export function apply(ctx: Context): void { loadHistoryTail: signal => history.loadTail(signal), loadOlderHistory: signal => history.loadOlder(signal), setActualDuration: (value) => { duration.set(value) }, + exportLog: async () => { + // The host streams the ZIP (root + descendant artifacts verbatim) + // from GET /api/session.export; the browser downloads the response. + const url = new URL('/api/session.export', window.location.origin) + url.searchParams.set('sessionId', sessionId) + url.searchParams.set('includeDescendants', 'true') + const response = await fetch(url) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`导出失败:HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) + } + const blob = await response.blob() + downloadBytes( + new Uint8Array(await blob.arrayBuffer()), + sessionLogZipFilename(sessionId), + 'application/zip', + ) + }, } }, }, TrajectoryView)) diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts new file mode 100644 index 0000000000..f5660dfddf --- /dev/null +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -0,0 +1,24 @@ +/** `trajectory` namespace dictionaries (the view tab label). */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'trajectory' + +/** The trajectory dictionary key set (the source of truth for both locales). */ +export type TrajectoryKey = 'view.trajectory' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The trajectory view tab label. */ + 'trajectory': TrajectoryKey + } +} + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh: Record = { + 'view.trajectory': '轨迹', +} + +/** English dictionary. */ +export const en: Record = { + 'view.trajectory': 'Trajectory', +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 687f4a4657..842486ea93 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,6 +13,18 @@ background: var(--dsw-alias-bg-layer-1); } +.exportError { + box-sizing: border-box; + flex: none; + width: 100%; + padding: 4px 10px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-danger, var(--dsw-alias-label-primary)); + background: var(--dsw-alias-bg-layer-2); + font: var(--dsw-font-xxs-12); + overflow-wrap: anywhere; +} + .ledger { position: relative; z-index: 0; diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 579a97337a..ebe08ad920 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -61,7 +61,7 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'sessionHistory']) + expect(surface.inject).toEqual(['slots', 'sessionHistory', 'locale']) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { @@ -74,8 +74,11 @@ describe('tsdown client artifact', () => { children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) // The plugin reads sessionHistory for its per-session history source; - // slot availability is tracked by slots.inject. + // slot availability is tracked by slots.inject, and the locale plugin + // backs the locale-aware view tab label. ctx.provide('sessionHistory', {}) + const locale = await import('@deepseek-ai/dsh-client-locale/client') + ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.spec.ts new file mode 100644 index 0000000000..e693ded89b --- /dev/null +++ b/packages/client/ui-trajectory/tests/export-log.spec.ts @@ -0,0 +1,20 @@ +// @vitest-environment node +/** + * Session-log export filename derivation. The archive itself is produced and + * streamed by the host (GET /api/session.export); this package only derives + * the download filename and triggers the browser save. + */ + +import { describe, expect, it } from 'vitest' +import { sessionLogZipFilename } from '../src/client/export-log.ts' + +describe('sessionLogZipFilename', () => { + it('keeps safe session ids verbatim', () => { + expect(sessionLogZipFilename('session-abc_1.2')).toBe('dsh-session-session-abc_1.2.zip') + }) + + it('neutralizes unsafe id characters that could shape the filename', () => { + expect(sessionLogZipFilename('../evil')).toBe('dsh-session-.._evil.zip') + expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') + }) +}) diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.spec.tsx new file mode 100644 index 0000000000..b3e2a4c014 --- /dev/null +++ b/packages/client/ui-trajectory/tests/toolbar.spec.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** Trajectory toolbar export button: click dispatch, in-flight disable, and error surfacing. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function baseProps(overrides: Partial = {}): TrajectoryToolbarProps { + return { + actualDuration: false, + onActualDurationChange: vi.fn(), + actualTime: false, + onActualTimeChange: vi.fn(), + allTurnsCollapsed: false, + onToggleAllTurns: vi.fn(), + allAssistantsCollapsed: false, + onToggleAllAssistants: vi.fn(), + searchQuery: '', + onSearchQueryChange: vi.fn(), + exporting: false, + onExport: vi.fn(), + exportError: null, + ...overrides, + } +} + +describe('TrajectoryToolbar export', () => { + it('renders the export button and dispatches the export callback on click', () => { + const onExport = vi.fn() + render() + const button = screen.getByRole('button', { name: '导出会话日志' }) + fireEvent.click(button) + expect(onExport).toHaveBeenCalledTimes(1) + }) + + it('disables the button while an export is in flight and blocks dispatch', () => { + const onExport = vi.fn() + render() + const button = screen.getByRole('button', { name: '导出会话日志' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + fireEvent.click(button) + expect(onExport).not.toHaveBeenCalled() + }) + + it('surfaces an export failure as the button title', () => { + render() + const button = screen.getByRole('button', { name: '导出会话日志' }) + expect(button.title).toBe('导出失败:internal boom') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index c9952be580..9c52417b61 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -27,6 +27,7 @@ import { } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -111,6 +112,12 @@ function standaloneDuration(): Pick< } } +function standaloneExport( + onExport: () => Promise = vi.fn(() => Promise.resolve()), +): Pick, 'exportLog'> { + return { exportLog: onExport } +} + function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore({ nodes, pending: [], partial: null, @@ -168,6 +175,8 @@ async function bench(snapshot = historySnapshot(NODES)) { slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) ctx.provide('sessionHistory', { source: () => history }) + // The locale plugin backs the locale-aware view tab label ('locale' in inject). + ctx.plugin({ inject: [...localeInject], apply: localeApply }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory } @@ -218,6 +227,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES loadHistoryTail: trajectory.loadHistoryTail, loadOlderHistory: trajectory.loadOlderHistory, setActualDuration: trajectory.setActualDuration, + exportLog: trajectory.exportLog, useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), } @@ -331,6 +341,17 @@ describe('tab switching in ConversationRoot', () => { expect(signal?.aborted).toBe(true) }) + it('labels the trajectory tab in the active locale', async () => { + const b = await bench() + const labelOf = () => tabsOf(b.slots).find(tab => tab.id === 'trajectory')?.label + expect(labelOf()).toBe('Trajectory') + const locale = b.ctx.get('locale') as { setLocale(id: string): void } + locale.setLocale('zh') + expect(labelOf()).toBe('轨迹') + locale.setLocale('en') + expect(labelOf()).toBe('Trajectory') + }) + it('opens a local record inspector and switches payload tabs without opening chat details', async () => { const b = await bench() mount(b.slots) @@ -1066,6 +1087,7 @@ describe('timeline projection', () => { ...standaloneProps([]), ...standaloneHistory(historySnapshot([])), ...standaloneDuration(), + ...standaloneExport(), }, )) expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() @@ -1073,6 +1095,55 @@ describe('timeline projection', () => { }) }) +describe('session log export', () => { + afterEach(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(URL, 'createObjectURL') + Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click') + }) + + it('downloads the host-streamed ZIP with descendants on click', async () => { + // exportLog always fetches a URL instance, so the mock's shape stays narrow. + const fetchMock = vi.fn(async (input: URL) => { + expect(input.pathname).toBe('/api/session.export') + expect(input.searchParams.get('sessionId')).toBe(SID) + expect(input.searchParams.get('includeDescendants')).toBe('true') + return new Response('zip-bytes') + }) + vi.stubGlobal('fetch', fetchMock) + const createObjectURL = vi.fn(() => 'blob:export') + URL.createObjectURL = createObjectURL + const clickAnchor = vi.fn() + HTMLAnchorElement.prototype.click = clickAnchor + const b = await bench(historySnapshot(NODES)) + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + fireEvent.click(screen.getByRole('button', { name: '导出会话日志' })) + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledOnce() + }) + // The blob download lands a few microtasks after the fetch settles. + // The blob download lands a few microtasks after the fetch settles. + await vi.waitFor(() => { + expect(createObjectURL).toHaveBeenCalled() + }) + expect(clickAnchor).toHaveBeenCalled() + }) + + it('surfaces the download failure in the visible alert bar', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 }))) + const b = await bench(historySnapshot(NODES)) + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + fireEvent.click(screen.getByRole('button', { name: '导出会话日志' })) + await vi.waitFor(() => { + const alert = screen.queryByRole('alert') + expect(alert).not.toBeNull() + expect(alert!.textContent).toContain('HTTP 404') + }) + }) +}) + describe('TrajectoryView branches', () => { it('persists the duration preference through the runtime snapshot-store seam', () => { const firstDuration = createTrajectoryDurationStore() @@ -1083,6 +1154,7 @@ describe('TrajectoryView branches', () => { const first = render( { firstDuration.set(value) }} />, @@ -1098,6 +1170,7 @@ describe('TrajectoryView branches', () => { render( { restoredDuration.set(value) }} />, @@ -1162,6 +1235,7 @@ describe('TrajectoryView branches', () => { Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} @@ -1196,6 +1270,7 @@ describe('TrajectoryView branches', () => { Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} @@ -1225,6 +1300,7 @@ describe('TrajectoryView branches', () => { Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} @@ -1274,6 +1350,7 @@ describe('TrajectoryView branches', () => { Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index f525474d9d..0d2dccb561 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../ui-conversation" }, From ced7caabf8f5a5447b490d998208262e55084caa Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:55 +0800 Subject: [PATCH 16/67] docs(notes): agent note for web session-log export --- ...026-08-10-web-session-log-export.i18n.yaml | 6 ++++ .../2026-08-10-web-session-log-export.md | 30 +++++++++++++++++++ .../2026-08-10-web-session-log-export.zh.md | 30 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml new file mode 100644 index 0000000000..60c70d5519 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +2026-08-10-web-session-log-export.md: 9d31e4deb640474230dd9b16ec02f2f36dc5ca56 +2026-08-10-web-session-log-export.zh.md: 0a2ba678b0ea56202493507165d94750761ce3d9 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md new file mode 100644 index 0000000000..9d31e4deb6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -0,0 +1,30 @@ +# Agent Note: Web session-log export via session.log + ZIP download + +Status: implemented + +English | [中文](2026-08-10-web-session-log-export.zh.md) + +## Problem + +The Trajectory view had no way to hand a debugging artifact to a human: the raw session log lived on disk and in the host, the client history face served folded projections (not raw entries), and a session with subagents spans many independent session logs. A bug report needs the complete raw log of the whole tree, in a shape that survives being emailed around. + +## Decision + +- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never materializes the whole archive (at most one descendant's artifact text beyond the preloaded root). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. +- **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **The UI just downloads**: the 导出 button fetches the endpoint and saves the response; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle no longer carries fflate (the earlier browser-entry-alias pitfall is moot). +- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button; a failure surfaces in a visible alert bar under the toolbar. + +## Alternatives considered + +- **`session.log` data RPC + client-side zip** — shipped first, rejected with the user: the browser pulls the full raw JSON (≈10× the final zip size) and compresses on the main thread; for the 23 MB sessions in real use the host-side stream is strictly better. The RPC was deleted with the migration rather than left as a dead public surface. +- **Single JSONL with envelope lines for multiple sessions** — rejected with the user: mixing sessions in one JSONL loses clean per-file boundaries; a ZIP keeps one canonical file per session. +- **jszip** — heavier (~100 kB) and its dependency graph pulls readable-stream browser mappings; fflate is purpose-built and small. +- **Vendoring fflate's browser entry** — the repo vendoring procedure targets cordis-scale pinned sources; a resolveId alias keeps the maintained dependency without shipping a copy (and host-side fflate needs no alias at all). + +## Consequences + +- Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. +- `readRaw` joins the persistence service as a concrete default (`undefined` for backends without a per-session artifact, e.g. SQLite) with a JSONL-backend override that owns the compression decode. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. +- Fixture mode (no host) answers 404 for the export, so the button's error bar explains the gap instead of hanging; the navigation-panes golden snapshot includes the 导出 button. +- Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md new file mode 100644 index 0000000000..0a2ba678b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -0,0 +1,30 @@ +# Agent Note:Web 会话日志导出——session.log RPC + ZIP 下载 + +状态:implemented + +[English](2026-08-10-web-session-log-export.md) | 中文 + +## 问题 + +Trajectory 视图没有任何方式把调试工件交到人手里:原始会话日志存放在磁盘与宿主侧,客户端历史面只提供折叠后的投影(而非原始事件),而带子代理的会话横跨多个相互独立的会话日志。bug 报告需要整棵会话树的完整原始日志,并且形态要能在被转发后仍然可用。 + +## 决策 + +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进内存(除预载的根外,最多同时持有一条后代的工件文本)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate(早先的浏览器入口别名坑随之消失)。 +- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示。 + +## 考虑过的替代方案 + +- **`session.log` 数据 RPC + 客户端打包**——先发布,后与用户共同否决:浏览器要拉取完整原始 JSON(约为最终 zip 的 10 倍)并在主线程压缩;对实际使用中 23 MB 级别的会话,宿主流式严格更优。迁移时把该 RPC 一并删除,而不是留作无消费者的公共接口。 +- **用信封行把多会话编码进单一 JSONL**——与用户共同否决:把多个会话混进一个 JSONL 会失去干净的按文件边界;ZIP 让每个会话保持一个规范文件。 +- **jszip**——更重(约 100 kB),依赖图还会拉入 readable-stream 的浏览器映射;fflate 专为此而生且体积小。 +- **将 fflate 浏览器入口 vendoring 进仓库**——仓库的 vendoring 流程面向 cordis 级别的固定源码;resolveId 别名在保持维护中的依赖的同时无需复制代码(宿主侧 fflate 根本不需要别名)。 + +## 后果 + +- 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 +- `readRaw` 以具体默认(无每会话工件的后端如 SQLite 返回 `undefined`)加入持久化服务,jsonl 后端覆写并自持压缩解码。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 +- fixture 模式(无宿主)对导出应答 404,按钮的错误条会解释这个缺口而非挂起;navigation-panes golden 快照包含「导出」按钮。 +- 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 From 9435ca62a68d53a87f93374a16fd2d0f4ccca4c8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:54:48 +0800 Subject: [PATCH 17/67] fix(sandbox): reject overlapping Windows temp roots --- ...08-windows-acl-restricted-token-sandbox.md | 2 +- ...windows-acl-restricted-token-sandbox.zh.md | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/src/index.ts | 3 +- .../sandbox-local/tests/acl-grants.spec.ts | 17 +++++- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 4 +- .../sandbox/sandbox-windows-acl/README.zh.md | 4 +- .../sandbox/sandbox-windows-acl/src/index.ts | 3 ++ .../sandbox-windows-acl/src/path-boundary.ts | 40 ++++++++++++++ .../sandbox/sandbox-windows-acl/src/runner.ts | 5 +- .../sandbox-windows-acl/tests/acl.spec.ts | 22 +++++++- .../tests/path-boundary.spec.ts | 53 +++++++++++++++++++ .../sandbox-windows-acl/tests/runner.spec.ts | 16 ++++++ 16 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/src/path-boundary.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 027a5ac7cb..18b4a51c83 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor. ## How the restriction works (why no new identity) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index a0824cb037..bcdfa6f5a1 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这两个缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这两个缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付。 ## How the restriction works (why no new identity) diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index d76632e737..e8f0500876 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: eebb0df957f66585d8bbe35fee158776231f80d5 -README.zh.md: d4a9cef722134b3ffab238b43b73d83e13d4ee6d +README.md: e43133c7c5b64d7779162b790ee6cab7806fd100 +README.zh.md: 32743d1b0aba5bed41ee53d90c4ed44dc936161c diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index eebb0df957..e43133c7c5 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -12,7 +12,7 @@ Policy is per call; the provider stores only the mechanism and cached runner ver The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. -The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. +The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. A workspace equal to or containing the platform temp root fails before any ACL mutation because its inheritable workspace ACE would otherwise reach every private temp child. [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index d4a9cef722..32743d1b0a 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -12,7 +12,7 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 -Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此,共享工作区的会话会共享预期的写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权;runner 会为无 agent(智能体)的调用提供同样的逐调用隔离。 +Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此,共享工作区的会话会共享预期的写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权;runner 会为无 agent(智能体)的调用提供同样的逐调用隔离。如果工作区等于或包含平台临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的工作区 ACE 会延伸到每个私有临时子目录。 [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止约定漂移。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index dc86ba8058..9aec3f04d9 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -37,7 +37,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -390,6 +390,7 @@ export class LocalSandboxProvider extends SandboxProvider { * @returns the pair's private temp directory and write capability. */ private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability { + assertTempRootOutsideWorkspace(workspaceRoot, tmpdir()) const writeSid = workspaceWriteSid(workspaceRoot) if (!this.workspaceGrants.has(workspaceRoot)) { const grant = AclWriteGrant.create(writeSid) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index 2de4af3a3d..d720ccf216 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -5,7 +5,7 @@ * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -51,6 +51,13 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { } return { AclWriteGrant: MockAclWriteGrant, + assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => { + const workspace = realpathSync.native(workspaceRoot) + const temp = realpathSync.native(tempRoot) + if (temp === workspace || temp.startsWith(`${workspace}${process.platform === 'win32' ? '\\' : '/'}`)) { + throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) + } + }, workspaceWriteSid: () => 'S-1-4-42-42', tempWriteSid: (path: string) => `TEMP:${path}`, } @@ -238,6 +245,14 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) + it('rejects a workspace containing the ambient temp root before any ACL mutation', async () => { + const { sandbox } = await setup() + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: realpathSync.native(tmpdir()), sessionId: SessionId('overlap'), + })).toThrow(/temp root must be outside the workspace/u) + expect(mockState.grants).toHaveLength(0) + }) + it('temp grant creation/add failures remove the random directory; cleanup failures aggregate', async () => { try { const { sandbox } = await setup() diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 2425f2d0de..51d933fbb1 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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/sandbox/sandbox-windows-acl/README.md -README.md: 9134fa7b07699f4de8a264c8831aa2347f2d5e18 -README.zh.md: 49b5ebbd792395c86e1031f4efc49e3042dbfe55 +README.md: 280dc2b38844feff87eb792223b87ead251f3e16 +README.zh.md: 06121c3142bd788d0e1fe8cfa38fa8a668bb270a diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 9134fa7b07..280dc2b388 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -50,7 +50,7 @@ node runner.js --workspace --temp --mode The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its self-managed temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Workspace reuse and temp isolation**: the seam materializes the deterministic workspace SID's ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache), then creates a random private temp directory and distinct revocable SID for each live session/workspace pair. It passes both identities as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter rather than a collision or inherited capability. Without the pair, `--temp` names a root: an agentless/standalone workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips `SetNamedSecurityInfoW` when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Known cost: the first grant on a big workspace tree blocks for that eager propagation once per workspace per machine. +**Workspace reuse and temp isolation**: the seam materializes the deterministic workspace SID's ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache), then creates a random private temp directory and distinct revocable SID for each live session/workspace pair. It passes both identities as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter rather than a collision or inherited capability. Without the pair, `--temp` names a root: an agentless/standalone workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. A workspace equal to or containing that root is rejected before any grant because its inheritable workspace ACE would otherwise authorize every private child; the direct API likewise rejects overlap between any writable root and the actual private temp directory. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips `SetNamedSecurityInfoW` when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Known cost: the first grant on a big workspace tree blocks for that eager propagation once per workspace per machine. Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): - `workspace-write` (logon SID, Everyone, workspace SID, temp SID): the workspace and the session's PRIVATE temp subdirectory carry separate Write grants; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries. @@ -78,7 +78,7 @@ The koffi struct definitions assert their sizes against the probe at module load - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The ambient temp root is never granted implicitly.** A direct `AclSandbox` workspace-write caller must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or explicitly disable temp writes with `tempDir: null`. The seam creates a random private directory; agentless runner calls treat `--temp` as the parent root and create their own random child. +- **The ambient temp root is never granted implicitly.** A direct `AclSandbox` workspace-write caller must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or explicitly disable temp writes with `tempDir: null`. The actual temp directory must be disjoint from every writable root. The seam creates a random private directory; agentless runner calls treat `--temp` as the parent root and create their own random child, but reject a workspace equal to or containing that parent before any ACL mutation. - **The confined child's temp capability is private per live session/workspace pair.** The runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to that private directory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). The temp ACE and directory are removed on provider disposal, or after each agentless invocation. A crash can leave inert `%TEMP%` litter, but a resumed provider chooses a new random path and SID instead of colliding with or reauthorizing the residue. The native runner suite proves that two tokens sharing the same workspace SID cannot write one another's temp directories. - **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 49b5ebbd79..06121c3142 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -52,7 +52,7 @@ node runner.js --workspace --temp --mode runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 -**工作区复用与临时隔离**:seam 先把确定性工作区 SID 的 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID。它把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW`(应用该 ACE 会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。已知代价:大型工作区树的首次授权会阻塞整次急切传播,每台机器每个工作区一次。 +**工作区复用与临时隔离**:seam 先把确定性工作区 SID 的 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID。它把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。工作区若等于或包含该根目录,会在任何授权前被拒绝,因为否则其可继承的工作区 ACE 会向每个私有子目录授权;直接 API 同样拒绝任何可写根目录与实际私有临时目录重叠。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW`(应用该 ACE 会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。已知代价:大型工作区树的首次授权会阻塞整次急切传播,每台机器每个工作区一次。 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): - `workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID):工作区与会话的**私有**临时子目录分别携带 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。 @@ -80,7 +80,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 -- **环境临时根目录绝不会被隐式授权。** 直接使用 `AclSandbox` 的 workspace-write 调用方必须提供一个已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或通过 `tempDir: null` 显式禁用临时写入。seam 会创建随机私有目录;无 agent runner 调用把 `--temp` 视为父根目录,并自行创建随机子目录。 +- **环境临时根目录绝不会被隐式授权。** 直接使用 `AclSandbox` 的 workspace-write 调用方必须提供一个已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或通过 `tempDir: null` 显式禁用临时写入。实际临时目录不得与任何可写根目录重叠。seam 会创建随机私有目录;无 agent runner 调用把 `--temp` 视为父根目录并自行创建随机子目录,但如果工作区等于或包含该父根目录,就会在任何 ACL 改动前拒绝调用。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为该私有目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。临时 ACE 与目录会在提供方 dispose 时移除,或在每次无 agent 调用后移除。崩溃可能留下失效的 `%TEMP%` 垃圾,但恢复后的提供方会选择新的随机路径和 SID,而不会与残留发生冲突或重新向其授权。原生 runner 套件证明,共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 4675a7c886..e074e055de 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -47,12 +47,14 @@ import { grantWrite, revokeWrite } from './acl.ts' import { Win32Error } from './errors.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' +import { assertPrivateTempDisjoint } from './path-boundary.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' +export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' export { Win32Error } from './errors.ts' @@ -242,6 +244,7 @@ export class AclSandbox { if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`) } + assertPrivateTempDisjoint(this.writableDirs, tempDir) } this.tempDirResolved = tempDir diff --git a/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts b/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts new file mode 100644 index 0000000000..7c8dcb2596 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts @@ -0,0 +1,40 @@ +/** + * Canonical directory-boundary checks for the Windows ACL workspace and + * private-temp capabilities. + * @module @deepseek-ai/dsh-sandbox-windows-acl/path-boundary + */ + +import { realpathSync } from 'node:fs' +import { isAbsolute, relative, sep } from 'node:path' + +/** Whether `root` is the same canonical directory as `candidate` or contains it. */ +function containsDirectory(root: string, candidate: string): boolean { + const relation = relative(realpathSync.native(root), realpathSync.native(candidate)) + return relation === '' || (!isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${sep}`)) +} + +/** + * Reject a temp parent that is inside the workspace: every child created + * below it would inherit the standing workspace capability. + * @param workspaceRoot - the canonical workspace root that receives the standing ACE. + * @param tempRoot - the existing parent beneath which a private temp child would be created. + */ +export function assertTempRootOutsideWorkspace(workspaceRoot: string, tempRoot: string): void { + if (containsDirectory(workspaceRoot, tempRoot)) { + throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) + } +} + +/** + * Reject overlap between an actual private temp directory and any writable + * directory: either inheritance direction would merge the two capabilities. + * @param writableDirs - directories carrying the standing workspace capability. + * @param tempDir - the existing directory carrying the revocable temp capability. + */ +export function assertPrivateTempDisjoint(writableDirs: readonly string[], tempDir: string): void { + for (const writableDir of writableDirs) { + if (containsDirectory(writableDir, tempDir) || containsDirectory(tempDir, writableDir)) { + throw new Error(`AclSandbox private temp directory must be disjoint from writable directories: writable=${writableDir}; temp=${tempDir}`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 1a63a18545..2d0b2e9f4b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -48,7 +48,7 @@ import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' import { win32 } from './ffi.ts' -import { AclSandbox } from './index.ts' +import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts' import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' @@ -126,6 +126,9 @@ async function main(): Promise { if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) { fail('workspace-write requires --write-sid and --temp-write-sid together') } + if (parsed.mode === 'workspace-write') { + assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp) + } const api = await win32() // Ignore this process's own CTRL+C: the confined child (same console) keeps diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 729123b7d7..91bb6cdc92 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -9,7 +9,7 @@ * whose per-test lock file is removed in cleanup. */ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -207,6 +207,26 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false) }) + it('rejects an overlapping private temp directory before applying either capability', async () => { + const workspaceDir = scratch() + const nestedTemp = join(workspaceDir, 'temp') + const writeSid = 'S-1-4-9000-30' + const privateTempSid = 'S-1-4-9000-30-1' + mkdirSync(nestedTemp) + const sandbox = new AclSandbox({ + writableDirs: [workspaceDir], + tempDir: nestedTemp, + writeSid, + tempWriteSid: privateTempSid, + mode: 'workspace-write', + }) + + await expect(sandbox.init()).rejects.toThrow(/private temp directory must be disjoint/u) + const api = await win32() + expect(readDirectAces(api, workspaceDir).some(ace => ace.sid === writeSid)).toBe(false) + expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false) + }) + it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => { const dir = scratch() expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })) diff --git a/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts new file mode 100644 index 0000000000..cab6ca8e1b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts @@ -0,0 +1,53 @@ +/** Canonical path-overlap checks that keep workspace and temp capabilities separate. */ + +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from '../src/path-boundary.ts' + +describe('Windows ACL temp path boundary', () => { + const scratchDirs: string[] = [] + + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-boundary-')) + scratchDirs.push(dir) + return dir + } + + it('rejects a temp root equal to or below the workspace', () => { + const workspace = scratch() + const nested = join(workspace, 'temp') + mkdirSync(nested) + + expect(() => assertTempRootOutsideWorkspace(workspace, workspace)).toThrow(/temp root must be outside the workspace/u) + expect(() => assertTempRootOutsideWorkspace(workspace, nested)).toThrow(/temp root must be outside the workspace/u) + }) + + it('accepts a temp parent above the workspace because a fresh child is a sibling', () => { + const tempRoot = scratch() + const workspace = join(tempRoot, 'workspace') + mkdirSync(workspace) + + expect(() => assertTempRootOutsideWorkspace(workspace, tempRoot)).not.toThrow() + }) + + it('requires an actual private temp directory to be disjoint in either direction', () => { + const root = scratch() + const workspace = join(root, 'workspace') + const nestedTemp = join(workspace, 'temp') + const siblingTemp = join(root, 'sibling-temp') + mkdirSync(workspace) + mkdirSync(nestedTemp) + mkdirSync(siblingTemp) + + expect(() => assertPrivateTempDisjoint([workspace], nestedTemp)).toThrow(/must be disjoint/u) + expect(() => assertPrivateTempDisjoint([nestedTemp], workspace)).toThrow(/must be disjoint/u) + expect(() => assertPrivateTempDisjoint([workspace], siblingTemp)).not.toThrow() + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 36b3ce17d2..b6d7fd5e04 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -277,6 +277,22 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(tempB)).toBe(false) }, 30_000) + it('agentless workspace-write rejects a temp root inside the workspace before spawning', () => { + const overlapWorkspace = join(scratchRoot, 'overlap-workspace') + const nestedTempRoot = join(overlapWorkspace, 'temp') + const marker = join(overlapWorkspace, 'command-ran.txt') + mkdirSync(overlapWorkspace) + mkdirSync(nestedTempRoot) + + const result = runRunner([ + '--workspace', overlapWorkspace, '--temp', nestedTempRoot, '--mode', 'workspace-write', + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'ran')", marker, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(127) + expect(result.stderr).toContain('windows-acl-run: Windows ACL temp root must be outside the workspace') + expect(existsSync(marker)).toBe(false) + }, 15_000) + it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => { // Two-layer pin of the grandchild-spawn boundary: // - the token default DACL carries a restricting-SID ACE (set in init), From be880473e20b4b7cc285f688f486931df023b52b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:55:09 +0800 Subject: [PATCH 18/67] fix(tool-pwsh): scope named-pipe warning --- packages/bash/tool-pwsh/src/index.ts | 2 +- packages/bash/tool-pwsh/tests/tools.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 4c63d7cbc0..4aa3e4b720 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -125,7 +125,7 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' - + 'In the same modes, programs cannot open named pipes, so a command that captures another ' + + 'In both confined modes, programs cannot open named pipes, so a command that captures another ' + 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default ' + '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns ' + 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: ' diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 9c90d34d24..86b8267ff1 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -560,7 +560,7 @@ describe('sandbox escalation through ctx.approval', () => { expect(schema.description).toContain('approval prompt') expect(schema.description).toContain('ConstrainedLanguage') expect(schema.description).toContain('workspace-write stays in FullLanguage') - expect(schema.description).toContain('named pipes') + expect(schema.description).toContain('In both confined modes, programs cannot open named pipes') expect(schema.description).toContain('fails with EPERM') for (const args of [ From ba63251c48cb1ade938d46b8ab089325c44c89bc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:56:38 +0800 Subject: [PATCH 19/67] fix(sandbox): clear failed ACL temp state --- packages/sandbox/sandbox-windows-acl/src/index.ts | 1 + packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index e074e055de..fce54583d2 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -324,6 +324,7 @@ export class AclSandbox { this.token = undefined this.writeSidPtr = undefined this.tempWriteSidPtr = undefined + this.tempDirResolved = undefined this.grantedPaths = [] if (cleanupFailures.length > 0) { throw new AggregateError( diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index 8d20c4ab32..117052da1c 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -98,4 +98,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) + + it('failed init clears provisional temp state before a retry', async () => { + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const provisionalState = broken as unknown as { tempDirResolved: string | undefined } + provisionalState.tempDirResolved = isolatedTemp + + await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) + expect(broken.tempDir).toBeUndefined() + }, 15_000) }) From 6f21d00169585fa175b8860291f1eb7f39a909d8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:58:00 +0800 Subject: [PATCH 20/67] docs(sandbox): align capability SID terminology --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 ++-- ...08-windows-acl-restricted-token-sandbox.md | 2 +- ...windows-acl-restricted-token-sandbox.zh.md | 2 +- .../sandbox/sandbox-windows-acl/package.json | 2 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 20 +++++++++---------- .../sandbox/sandbox-windows-acl/src/grant.ts | 2 +- .../sandbox/sandbox-windows-acl/src/runner.ts | 4 ++-- .../sandbox-windows-acl/src/win32-abi.ts | 2 +- .../sandbox-windows-acl/src/workspace-sid.ts | 2 +- .../sandbox-windows-acl/tests/acl.spec.ts | 18 ++++++++--------- .../sandbox-windows-acl/tests/runner.spec.ts | 2 +- .../tests/workspace-sid.spec.ts | 4 ++-- 12 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 6d98659889..970799a54b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.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-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 027a5ac7cbed455f75e78ac6ab3328dc3de7def4 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: a0824cb037f9de377c344e6a4ca76e020fae2416 +2026-08-08-windows-acl-restricted-token-sandbox.md: 972713e02860218853f421aa700a8b60b33ada5b +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: da41cb3f9aa46bab96a5fbb6c035205b22c442aa diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 18b4a51c83..972713e028 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -14,7 +14,7 @@ Implement the rung directly on the raw ACL mechanism: duplicate the caller's tok ## How the restriction works (why no new identity) -The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the orphan-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement. +The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index bcdfa6f5a1..da41cb3f9a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -14,7 +14,7 @@ Status: implemented ## How the restriction works (why no new identity) -身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过孤儿 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 +身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 ## Alternatives considered diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 2f13b71296..963d286cf0 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", - "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam", + "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index ef787cc410..eded0f86a8 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -1,5 +1,5 @@ /** - * ACL editing helpers: grant/revoke the orphan write SID on a directory via + * ACL editing helpers: grant/revoke a capability SID on a directory via * SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with * the failure handling the POC lacks). Every API call is checked and every * failure is reported with the API name, the exact Win32 code, the formatted @@ -39,7 +39,7 @@ export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType - entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID + entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the capability SID return entry } @@ -181,16 +181,16 @@ function mergeAndApply( /** * True when the explicit DACL already carries the EXACT write grant this * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the - * orphan SID). Every field is read through koffi.decode at pointer offsets — + * capability SID). Every field is read through koffi.decode at pointer offsets — * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the * ACE after the 4-byte mask — there is no pointer to read; reading one * yields garbage addresses and crashed EqualSid, verified by gdb), so it is - * compared field-by-field against the orphan SID through bounded offset + * compared field-by-field against the capability SID through bounded offset * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" * so the caller falls back to the merge-apply path, which owns the robust * failure handling. * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). - * @param sidPtr - the orphan write SID to match. + * @param sidPtr - the capability SID to match. * @returns whether the exact grant ACE is already present. */ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { @@ -213,7 +213,7 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { } /** - * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID + * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID * on `path`, inheriting to subcontainers and objects. Idempotent: when the * directory's current explicit DACL already carries the exact ACE (the * per-session grant surviving from a previous server lifetime), the @@ -226,7 +226,7 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL gains the grant (the workspace or temp root). - * @param sidPtr - the orphan write SID the ACE names. + * @param sidPtr - the capability SID the ACE names. */ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { withPathLock(api, path, () => { @@ -244,15 +244,15 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): } /** - * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS + * Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS * merge — other entries are preserved). Returns whether an ACE removal was * attempted (false when the directory carries no DACL at all). * * Runs under the per-path lock (the whole get-merge-set sequence); the * descriptor/ACL allocation contract lives on {@link readCurrentDacl}. * @param api - the binding table. - * @param path - the directory whose DACL loses the orphan-SID ACEs. - * @param sidPtr - the orphan write SID whose ACEs are removed. + * @param path - the directory whose DACL loses the capability-SID ACEs. + * @param sidPtr - the capability SID whose ACEs are removed. * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). */ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index eed5f0c828..edb0345579 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -42,7 +42,7 @@ export class AclWriteGrant { /** * Parse the SID string and open the binding table (lazily, once per * server). Fail-closed: any failure throws — nothing is granted yet. - * @param writeSid - the orphan write SID string (`S-1-4-x-y`). + * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). */ diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 2d0b2e9f4b..8d5fe35645 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -17,8 +17,8 @@ * - workspace-write: the workspace and temp directories carry distinct * capability-SID Write grants; other ACL-addressable writes are denied * except for the documented Everyone and hard-link boundaries. - * - read-only: no orphan-SID grants; the restricting list carries no orphan - * SID, so a standing grant ACE from an earlier + * - read-only: no capability-SID grants; the restricting list carries no + * capability SID, so a standing grant ACE from an earlier * workspace-write period stays inert. BOTH modes drop Authenticated Users * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the * Public tree writes are denied); the two lists share the keep-alive group diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 8e85eced3c..5af4496af7 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -63,7 +63,7 @@ export const FILE_DELETE_CHILD = 0x0040 // security boundary). /** * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and - * FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant + * FILE_DELETE_CHILD — the write+delete access mask the capability-SID ACEs grant * (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are * deliberately excluded: they would let the confined child take ownership or * rewrite DACLs. diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts index 46a64d5710..db313ce092 100644 --- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -28,7 +28,7 @@ import { createHash } from 'node:crypto' /** * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit, - * matching the orphan shape the token and ACE layers already carry). + * matching the workspace-capability shape the token and ACE layers carry). * @param workspaceRoot - the canonical workspace path. * @returns the SDDL string form. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 91bb6cdc92..8d548a16df 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -120,7 +120,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { const api = await win32() const dir = scratch() const usersSid = sidFromString(api, 'S-1-5-32-545') - const orphanSid = sidFromString(api, 'S-1-4-4242-1') + const capabilitySid = sidFromString(api, 'S-1-4-4242-1') try { // Install one explicit ACE (Users + benign read mask) with the // package's own bindings, exactly like a pre-existing explicit DACL @@ -137,37 +137,37 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) expect(isNullPtr(freed)).toBe(true) - grantWrite(api, dir, orphanSid) - revokeWrite(api, dir, orphanSid) + grantWrite(api, dir, capabilitySid) + revokeWrite(api, dir, capabilitySid) const aces = readDirectAces(api, dir) expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed } finally { if (!isNullPtr(usersSid)) api.localFree(usersSid) - if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) } }) it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { const api = await win32() const dir = scratch() - const orphanSid = sidFromString(api, 'S-1-4-4242-2') + const capabilitySid = sidFromString(api, 'S-1-4-4242-2') const apply = vi.spyOn(api, 'setNamedSecurityInfoW') try { - grantWrite(api, dir, orphanSid) + grantWrite(api, dir, capabilitySid) expect(apply).toHaveBeenCalledTimes(1) // The exact ACE now stands (the per-session grant surviving from a // previous server lifetime): the second grant is a DACL read only. - grantWrite(api, dir, orphanSid) + grantWrite(api, dir, capabilitySid) expect(apply).toHaveBeenCalledTimes(1) const aces = readDirectAces(api, dir) expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) - revokeWrite(api, dir, orphanSid) + revokeWrite(api, dir, capabilitySid) expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) } finally { apply.mockRestore() - if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) } }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index b6d7fd5e04..19dfdaf106 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -330,7 +330,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // The reported defect: a session that materialized its grant in // workspace-write keeps the ACE standing for the server lifetime. After // switching to read-only, the restricted token's read-only list must carry NO - // orphan SID — the standing ACE stays but the pass-2 check cannot use + // capability SID — the standing ACE stays but the pass-2 check cannot use // it, so the workspace write is denied (previously it LEAKED). The // switch back reuses the SAME standing ACE: the re-upgrade write lands // without any re-grant. diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts index 8581f39112..7fdef07d53 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -1,7 +1,7 @@ /** * workspaceWriteSid tests: the per-workspace write identity is deterministic * (the same canonical path always derives the same SID — the property the - * cross-session grant reuse rests on), orphan-shaped, distinct across + * cross-session grant reuse rests on), capability-shaped, distinct across * workspaces, and byte-sensitive (the canonical path is the caller's * contract; an alias spelling derives a second identity, self-healing at * the cost of one extra tree propagation). @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' import { tempWriteSid, workspaceWriteSid } from '../src/index.ts' describe('workspaceWriteSid', () => { - it('derives a stable orphan-shaped SID per workspace path', () => { + it('derives a stable capability-shaped SID per workspace path', () => { const first = workspaceWriteSid('C:\\Users\\agent\\repo') const second = workspaceWriteSid('C:\\Users\\agent\\repo') expect(first).toBe(second) From ee21d593b6ca63eb7d255669c0e0ab2c83449e8e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:58:37 +0800 Subject: [PATCH 21/67] test(sandbox): isolate temp cleanup failures --- packages/sandbox/sandbox-local/tests/acl-grants.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index d720ccf216..9fffc82f8b 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -276,12 +276,16 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { expect(failedTempGrant?.added).toHaveLength(1) expect(existsSync(failedTempGrant?.added[0]?.path ?? '')).toBe(false) + mockState.addFailureStanding = false + mockState.addFailure = new Error('temp add exploded') sandbox.internals.rmTempDir = () => { throw new Error('temp rm exploded') } expect(() => sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('rm-fail'), })).toThrow(/temp grant materialization failed and its cleanup also failed/u) delete sandbox.internals.rmTempDir + mockState.addFailureStanding = false + mockState.addFailure = new Error('temp add exploded') mockState.disposeFailure = new Error('temp cleanup exploded') expect(() => sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('aggregate-fail'), From 8143f01678abb5dd79ce6b8c0719c0cb5e28a75e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:57:29 +0800 Subject: [PATCH 22/67] test(sandbox): satisfy path boundary lint --- .../tests/path-boundary.spec.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts index cab6ca8e1b..59d8297280 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts @@ -25,8 +25,12 @@ describe('Windows ACL temp path boundary', () => { const nested = join(workspace, 'temp') mkdirSync(nested) - expect(() => assertTempRootOutsideWorkspace(workspace, workspace)).toThrow(/temp root must be outside the workspace/u) - expect(() => assertTempRootOutsideWorkspace(workspace, nested)).toThrow(/temp root must be outside the workspace/u) + expect(() => { + assertTempRootOutsideWorkspace(workspace, workspace) + }).toThrow(/temp root must be outside the workspace/u) + expect(() => { + assertTempRootOutsideWorkspace(workspace, nested) + }).toThrow(/temp root must be outside the workspace/u) }) it('accepts a temp parent above the workspace because a fresh child is a sibling', () => { @@ -34,7 +38,9 @@ describe('Windows ACL temp path boundary', () => { const workspace = join(tempRoot, 'workspace') mkdirSync(workspace) - expect(() => assertTempRootOutsideWorkspace(workspace, tempRoot)).not.toThrow() + expect(() => { + assertTempRootOutsideWorkspace(workspace, tempRoot) + }).not.toThrow() }) it('requires an actual private temp directory to be disjoint in either direction', () => { @@ -46,8 +52,14 @@ describe('Windows ACL temp path boundary', () => { mkdirSync(nestedTemp) mkdirSync(siblingTemp) - expect(() => assertPrivateTempDisjoint([workspace], nestedTemp)).toThrow(/must be disjoint/u) - expect(() => assertPrivateTempDisjoint([nestedTemp], workspace)).toThrow(/must be disjoint/u) - expect(() => assertPrivateTempDisjoint([workspace], siblingTemp)).not.toThrow() + expect(() => { + assertPrivateTempDisjoint([workspace], nestedTemp) + }).toThrow(/must be disjoint/u) + expect(() => { + assertPrivateTempDisjoint([nestedTemp], workspace) + }).toThrow(/must be disjoint/u) + expect(() => { + assertPrivateTempDisjoint([workspace], siblingTemp) + }).not.toThrow() }) }) From bf70da473b9ed19eed58fa25e54fb89ca893435e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:49:57 +0800 Subject: [PATCH 23/67] fix(session-persistence): async readRaw default and full branch coverage The inherited readRaw default now rejects like the async backend overrides instead of throwing synchronously, and its arms plus the JSONL override's retry loop, zero-frame, and corrupt-header branches get dedicated tests. --- .../tests/jsonl.spec.ts | 20 +++++++++++++++++++ .../tests/zstd.spec.ts | 12 +++++++++++ .../session/session-persistence/src/index.ts | 4 ++-- .../tests/persistence.spec.ts | 12 +++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 390d0e9048..7f760c7281 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -237,6 +237,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() }) + it('readRaw rejects a corrupt header line instead of exporting it', async () => { + const m = meta('raw-corrupt', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeFile(rawLogPath(root, '/work', m.id), 'not a header line\n{"type":"turn/start","seq":0}\n') + await expect(ctx.sessionPersistence.readRaw(m.id)).rejects.toThrow(/corrupt session log/) + }) + + it('readRaw retries when the file revision changes during the read', async () => { + const m = meta('raw-revision-race', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + statRace.path = rawLogPath(root, '/work', m.id) + + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + // Two stat calls per iteration; the mocked revision change forces a retry. + expect(statRace.reads).toBe(4) + }) + it('keeps the same location on resume and gives a fork its own location', async () => { const parent = meta('location-parent', '/work') const parentLocation = ctx.sessionPersistence.locate(parent) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 37d3e6c13b..c54dca4c08 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -377,6 +377,18 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) }) + it('readRaw is undefined for a zstd artifact that carries no frame', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-zero-frame', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + // Overwrite the physical artifact with a short buffer: frame scanning + // answers zero frames before any magic check, so readRaw reports no artifact. + await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) + expect(await ctx.sessionPersistence.readRaw(header.id)).toBeUndefined() + }) + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 0889a60edf..aedfd8df5a 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -106,9 +106,9 @@ export abstract class SessionPersistence extends Service { * @returns the raw artifact plus its parsed header, or `undefined` when the * session is absent or the backend owns no per-session artifact. */ - readRaw(_id: SessionId, signal?: AbortSignal): Promise { + async readRaw(_id: SessionId, signal?: AbortSignal): Promise { signal?.throwIfAborted() - return Promise.resolve(undefined) + return undefined } /** diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index d3e715b085..99f6acc756 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -246,6 +246,18 @@ runPersistenceContract('memory', async () => { } }) +describe('the inherited readRaw default', () => { + it('answers undefined and honors an aborted signal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + expect(await ctx.sessionPersistence.readRaw(SessionId('any-session'))).toBeUndefined() + await expect( + ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()), + ).rejects.toThrow() + }) +}) + // Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are // atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch. runCoordinatorContract('memory', async (): Promise => { From beb1d0601fb0516c187594d084def70abb6e6175 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:09 +0800 Subject: [PATCH 24/67] =?UTF-8?q?fix(apiproxy):=20address=20session-export?= =?UTF-8?q?=20review=20=E2=80=94=20surrogate-safe=20chunks,=20backpressure?= =?UTF-8?q?=20drain,=20strict=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk boundaries never split a surrogate pair (a lone high surrogate re-encodes as U+FFFD and silently corrupts the exported artifact), production yields whenever the response queue fills so a slow consumer bounds the accumulation, includeDescendants rejects values other than true/false instead of silently under-exporting, the dead missing-services arm is deleted by narrowing the streaming deps, and the readRaw failure answers 500 without leaking host paths into the browser error bar. --- packages/host/apiproxy/src/api-proxy.ts | 15 ++- .../host/apiproxy/src/api/downloads.schema.ts | 8 +- packages/host/apiproxy/src/session-export.ts | 103 ++++++++++++------ .../apiproxy/tests/session-export.spec.ts | 84 +++++++++++++- 4 files changed, 171 insertions(+), 39 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 6301b9f04c..b5dcdbb4e2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -45,6 +45,7 @@ import { sessionLogExportDeps, sessionLogZipFilename, streamSessionLogZip, + type SessionLogExportReady, } from './session-export.ts' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { @@ -3366,17 +3367,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro { status: 500 }, ) } + const ready: SessionLogExportReady = { + sessionQuery: deps.sessionQuery, + sessionPersistence: deps.sessionPersistence, + } let root: SessionRawArtifact | undefined try { - root = await deps.sessionPersistence.readRaw(request.sessionId) - } catch (error: unknown) { - return new Response(String(error), { status: 500 }) + root = await deps.sessionPersistence.readRaw(request.sessionId, signal) + } catch { + // Backend read failure: answer 500 without echoing the error, which + // may carry absolute host paths into the browser error bar. + return new Response('session log export failed to read the stored artifact', { status: 500 }) } if (root === undefined) { return new Response('session not found', { status: 404 }) } return new Response( - streamSessionLogZip(deps, root, request.sessionId, request.includeDescendants === true, signal), + streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal), { headers: { 'content-type': 'application/zip', diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts index d324711b78..8a5b371e7f 100644 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -10,11 +10,15 @@ import { z } from 'zod' import type { DownloadsApi } from './downloads.ts' import { sessionIdSchema } from './sessions.schema.ts' -/** session.export query params → the sessionLog request. */ +/** + * session.export query params → the sessionLog request. `includeDescendants` + * accepts exactly `true`/`false`/absent; any other value is rejected (400) so + * a misspelled flag cannot silently under-export. + */ export const sessionLogQuerySchema = z .object({ sessionId: sessionIdSchema, - includeDescendants: z.string().optional(), + includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(), }) .transform(query => ({ sessionId: query.sessionId, diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts index 62d4ffcd05..1386d511b2 100644 --- a/packages/host/apiproxy/src/session-export.ts +++ b/packages/host/apiproxy/src/session-export.ts @@ -4,9 +4,13 @@ * original base name (`session.jsonl`); each subagent descendant under * `subagents//`. No manifest is written — every file is * byte-identical to the backend's durable artifact and self-describing - * through its own header line. Compression happens on the host with fflate's - * streaming Zip API, so the response is chunked as it is produced and the - * host never materializes the whole archive. + * through its own header line. Compression runs on the host with fflate's + * streaming Zip API, so the archive bytes are produced incrementally and the + * host never holds the whole archive in one buffer; production yields to the + * consumer whenever the response queue fills past its high-water mark, so a + * slow consumer bounds the accumulation instead of piling up the whole + * archive (fflate's callback is synchronous — this drain point is the only + * backpressure available). * @module */ @@ -22,6 +26,12 @@ export interface SessionLogExportDeps { readonly sessionPersistence: SessionPersistence | undefined } +/** The export services narrowed to the mounted ones streaming actually reads. */ +export interface SessionLogExportReady { + readonly sessionQuery: SessionQueryService + readonly sessionPersistence: SessionPersistence +} + /** * Resolve the persistence and session-query services a log export needs. * @param ctx - the composed host context. @@ -33,6 +43,7 @@ export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { sessionPersistence: ctx.get('sessionPersistence'), } } + /** One exported artifact: the stored text plus the zip path it lands at. */ export interface SessionLogZipEntry { /** Zip entry path (root filename verbatim; descendants under `subagents//`). */ @@ -43,13 +54,15 @@ export interface SessionLogZipEntry { /** * One safe zip path segment from an untrusted session id. Session ids are - * host-controlled, but the brand allows any non-empty string, so `../` and - * separator characters are neutralized before they can shape archive entries. + * host-controlled, but the brand allows any non-empty string, so `../`, dot + * segments, and separator characters are neutralized before they can shape + * archive entries. Distinct ids may collapse onto one segment (id collision + * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept). * @param id - the raw session id. * @returns a filesystem-safe single path segment. */ function safeSessionIdSegment(id: string): string { - return id.replace(/[^A-Za-z0-9._-]/g, '_') + return id.replace(/[^A-Za-z0-9_-]/g, '_') } /** @@ -60,13 +73,14 @@ function safeSessionIdSegment(id: string): string { export function sessionLogZipFilename(sessionId: string): string { return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` } + /** * Yield the export entries in zip order: the preloaded root artifact first, * then every subagent descendant in lineage order, each read from the * persistence backend right before it is yielded and dropped after the * consumer moves on (the host holds at most one descendant's artifact text at * a time beyond the root). - * @param deps - the export services. + * @param deps - the mounted export services (the caller answered 500 before this runs). * @param root - the already-read root artifact (read by the caller so the * missing-session path can answer cleanly before streaming starts). * @param sessionId - the root session id. @@ -75,7 +89,7 @@ export function sessionLogZipFilename(sessionId: string): string { * @returns the export entries in zip order. */ export async function* sessionLogZipEntries( - deps: SessionLogExportDeps, + deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, @@ -83,13 +97,6 @@ export async function* sessionLogZipEntries( ): AsyncGenerator { yield { path: root.filename, content: root.content } if (!includeDescendants) return - const sessionQuery = deps.sessionQuery - const sessionPersistence = deps.sessionPersistence - if (sessionQuery === undefined || sessionPersistence === undefined) { - // The caller validated services before the stream started; this arm is - // unreachable today and guards a future caller that skips the check. - throw new Error('session log export is unavailable: missing session-query or session-persistence service') - } const seen = new Set([sessionId]) const collect = async function* ( nodes: readonly SessionLineageNode[], @@ -99,7 +106,7 @@ export async function* sessionLogZipEntries( const id = node.session.header.id if (seen.has(id)) continue seen.add(id) - const raw = await sessionPersistence.readRaw(id) + const raw = await deps.sessionPersistence.readRaw(id) if (raw === undefined) { throw new Error(`subagent "${id}" has no stored log artifact`) } @@ -110,12 +117,48 @@ export async function* sessionLogZipEntries( yield* collect(node.descendants) } } - const lineage = await sessionQuery.traceSession(sessionId) + const lineage = await deps.sessionQuery.traceSession(sessionId) yield* collect(lineage.descendants) } -/** How many code points of artifact text one zip push carries (bounded encode memory). */ -const PUSH_CHUNK_CODE_POINTS = 1 << 16 +/** How many code units of artifact text one zip push carries (bounded encode memory). */ +const PUSH_CHUNK_CODE_UNITS = 1 << 16 + +/** + * Push one artifact's text into a deflate stream in bounded chunks, never + * splitting a surrogate pair across a chunk boundary (a lone high surrogate + * re-encodes as U+FFFD and would silently corrupt the exported artifact). + * @param deflate - the zip entry's deflate stream. + * @param content - the artifact text verbatim. + * @param signal - optional cancellation; throws when aborted. + */ +async function pushArtifactChunks( + deflate: ZipDeflate, + content: string, + controller: ReadableStreamDefaultController, + signal?: AbortSignal, +): Promise { + const encoder = new TextEncoder() + let offset = 0 + let finalChunk: boolean + do { + signal?.throwIfAborted() + let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length) + if (end < content.length && end - offset > 1) { + // Back off one code unit when the boundary lands inside a surrogate + // pair: the pair then starts the next chunk whole. + const last = content.charCodeAt(end - 1) + if (last >= 0xd800 && last <= 0xdbff) end -= 1 + } + finalChunk = end >= content.length + deflate.push(encoder.encode(content.slice(offset, end)), finalChunk) + offset = end + /* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */ + if (controller.desiredSize !== null && controller.desiredSize < 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + } while (!finalChunk) +} /** * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is @@ -124,7 +167,7 @@ const PUSH_CHUNK_CODE_POINTS = 1 << 16 * then encoded and deflated in bounded chunks as it is produced, so the * archive bytes arrive incrementally. A descendant that fails to read errors * the stream (fail-loud, never silent under-export). - * @param deps - the export services. + * @param deps - the mounted export services (the caller answered 500 before this runs). * @param root - the already-read root artifact (first zip entry). * @param sessionId - the root session id. * @param includeDescendants - whether to include every subagent descendant. @@ -132,23 +175,25 @@ const PUSH_CHUNK_CODE_POINTS = 1 << 16 * @returns the zip byte stream. */ export function streamSessionLogZip( - deps: SessionLogExportDeps, + deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, signal?: AbortSignal, ): ReadableStream { - const encoder = new TextEncoder() return new ReadableStream({ start(controller) { - // fflate invokes the callback synchronously per compressed chunk; - // enqueued bytes stay bounded by the compressed archive size (the body - // consumer drains them over the wire as the stream is pulled). + // fflate invokes the callback synchronously per compressed chunk, so a + // single push can enqueue ahead of a slow consumer; pushArtifactChunks + // yields between chunks once the queue is over-full, bounding the + // accumulation to the queue high-water mark plus one push. const zip = new Zip((error, data, final) => { + /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ if (error) { controller.error(error) return } + /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */ if (data.byteLength > 0) controller.enqueue(data) if (final) controller.close() }) @@ -157,17 +202,13 @@ export function streamSessionLogZip( for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) { const deflate = new ZipDeflate(entry.path, { level: 6 }) zip.add(deflate) - const content = entry.content - for (let offset = 0; offset < content.length; offset += PUSH_CHUNK_CODE_POINTS) { - signal?.throwIfAborted() - const finalChunk = offset + PUSH_CHUNK_CODE_POINTS >= content.length - deflate.push(encoder.encode(content.slice(offset, offset + PUSH_CHUNK_CODE_POINTS)), finalChunk) - } + await pushArtifactChunks(deflate, entry.content, controller, signal) } zip.end() } catch (error) { // A mid-stream failure (missing descendant, cancellation, read // error) must fail the download rather than ship a truncated archive. + /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */ controller.error(error instanceof Error ? error : new Error(String(error))) } })() diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 479e2ac178..40070beaaa 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -43,7 +43,7 @@ function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageN async function buildApi( artifacts: Record, descendants: SessionLineageNode[] = [], - services: { query?: boolean; persistence?: boolean } = { query: true, persistence: true }, + services: { query?: boolean; persistence?: boolean | 'throw' } = { query: true, persistence: true }, ) { const ctx = new Context() await ctx.plugin(UserInteractionService) @@ -60,7 +60,10 @@ async function buildApi( } if (services.persistence) { ctx.provide('sessionPersistence', { - readRaw: async (id: SessionId) => artifacts[id], + readRaw: async (id: SessionId) => { + if (services.persistence === 'throw') throw new Error('/host/private/session.jsonl') + return artifacts[id] + }, } as never) } return createApiProxy(ctx, { @@ -125,6 +128,14 @@ describe('session.export download endpoint', () => { expect(response.status).toBe(400) }) + it('answers 400 for an includeDescendants value other than true or false', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), + ) + expect(response.status).toBe(400) + }) + it('answers 500 when the deployment mounts no persistence or session-query service', async () => { const api = await buildApi({}, [], { query: false, persistence: false }) const response = await toFetchHandler(api).fetch( @@ -146,4 +157,73 @@ describe('session.export download endpoint', () => { // than returning a truncated-but-valid archive. await expect(response.arrayBuffer()).rejects.toThrow() }) + + it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { + // The push loop slices by 2^16 code units and must back off one unit when + // the boundary lands inside a surrogate pair; otherwise the pair re-encodes + // as U+FFFD and the exported artifact is silently corrupted. + const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('splits a long artifact on a plain code-unit boundary without backoff', async () => { + // A boundary that lands on a BMP character needs no surrogate backoff; the + // round trip must still be byte-identical across the multi-chunk push. + const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('exports an empty artifact as an empty zip entry', async () => { + const root = { ...artifact('session-root'), content: '' } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') + }) + + it('exports a shared lineage node once (seen-set dedup)', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'child-b': artifact('child-b', sid('session-root')), + shared: artifact('shared', sid('child-a')), + }, [ + node('child-a', node('shared')), + node('child-b', node('shared')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/child-b/session.jsonl', + 'subagents/shared/session.jsonl', + ]) + }) + + it('answers 500 without leaking the backend error when the root artifact read fails', async () => { + const api = await buildApi({}, [], { query: true, persistence: 'throw' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to read the stored artifact') + expect(body).not.toContain('/host/private/') + }) }) From 53d9810461017799a85704642fdcf7f86d6aadbc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:20 +0800 Subject: [PATCH 25/67] fix(web): localize the trajectory toolbar and simplify the download Toolbar strings route through the locale dictionary's standard t seat (the export button no longer mixes languages in the English golden), exportLog passes the response blob straight to the browser save instead of copying it three times, the client id sanitizer rejects dot segments like the host one, and the fixture stub comment no longer misattributes the 404. --- .../client/connection/src/client/fixture.ts | 6 +- .../src/client/TrajectoryToolbar.tsx | 37 +++++++----- .../src/client/TrajectoryView.tsx | 7 ++- .../ui-trajectory/src/client/export-log.ts | 16 ++--- .../client/ui-trajectory/src/client/index.ts | 16 ++--- .../ui-trajectory/src/client/locales.ts | 58 ++++++++++++++++++- .../ui-trajectory/tests/export-log.spec.ts | 8 ++- .../ui-trajectory/tests/toolbar.spec.tsx | 6 ++ .../client/ui-trajectory/tests/views.spec.tsx | 30 ++++++---- 9 files changed, 130 insertions(+), 54 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 503cc8cc6b..08837db9c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2834,9 +2834,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) return Promise.resolve({ accepted: true }) }, - // The host-only streaming download has no in-memory counterpart: fixture - // mode answers 404 so the export button's error bar explains the gap - // instead of hanging. + // Satisfies the ApiProxy contract type only: the browser export button + // fetches GET /api/session.export directly (window.fetch), so this stub is + // never reached through the fixture's dispatch. downloads: { sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index fd05582b4b..2ff7a6092c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -1,6 +1,8 @@ /** Trajectory toolbar: timeline and ledger fold controls. */ +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { NS } from './locales.ts' import css from './TrajectoryToolbar.module.css' export interface TrajectoryToolbarProps { @@ -30,6 +32,8 @@ export interface TrajectoryToolbarProps { onExport: () => void /** Export failure message, shown while set; null while idle or successful. */ exportError: string | null + /** Translate a toolbar dictionary key. */ + t: TranslateNS } /** @@ -51,17 +55,18 @@ export function TrajectoryToolbar({ exporting, onExport, exportError, + t, }: TrajectoryToolbarProps) { return ( -
+
@@ -134,8 +139,8 @@ export function TrajectoryToolbar({ { onSearchQueryChange(event.currentTarget.value) }} /> diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 9d182b379b..2046a9813c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, SessionHistoryFace, SnapshotStore, @@ -187,8 +187,8 @@ function mergeSearchMatches( export function TrajectoryView({ useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog, - inspect, onInspectDone, -}: ConvViewProps & InjectFace) { + inspect, onInspectDone, t, +}: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) @@ -563,6 +563,7 @@ export function TrajectoryView({ exporting={exporting} onExport={onExport} exportError={exportError} + t={t} /> {exportError !== null && (
diff --git a/packages/client/ui-trajectory/src/client/export-log.ts b/packages/client/ui-trajectory/src/client/export-log.ts index 4a5ccd8bd1..ba3d1ca8ff 100644 --- a/packages/client/ui-trajectory/src/client/export-log.ts +++ b/packages/client/ui-trajectory/src/client/export-log.ts @@ -7,11 +7,13 @@ /** * Collapse an untrusted session id into one safe path/filename segment. + * Distinct ids may collapse onto one segment (impossible for the host-minted + * UUIDs, so no uniqueness suffix is kept). * @param id - the raw session id. * @returns a filesystem-safe single segment. */ function safeSessionIdSegment(id: string): string { - return id.replace(/[^A-Za-z0-9._-]/g, '_') + return id.replace(/[^A-Za-z0-9_-]/g, '_') } /** @@ -25,16 +27,16 @@ export function sessionLogZipFilename(sessionId: string): string { } /** - * Trigger a browser download of raw bytes. - * @param bytes - the file content. + * Trigger a browser download of a blob response. + * @param blob - the response body to save (passed straight through, no copy). * @param filename - the download filename. - * @param type - the MIME type. */ -export function downloadBytes(bytes: Uint8Array, filename: string, type: string): void { - const url = URL.createObjectURL(new Blob([bytes], { type })) +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() - URL.revokeObjectURL(url) + // Revoke one tick later: some browsers read the blob URL after click(). + setTimeout(() => URL.revokeObjectURL(url), 0) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index adfb20fb02..a325f31410 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' import { en, NS, zh } from './locales.ts' -import { downloadBytes, sessionLogZipFilename } from './export-log.ts' +import { downloadBlob, sessionLogZipFilename } from './export-log.ts' /** Required services: the conversation view slot, the independent history source, and the locale service. */ export const inject = ['slots', 'sessionHistory', 'locale'] @@ -33,6 +33,7 @@ export function apply(ctx: Context): void { name: 'conversation.view', id: 'trajectory', order: 10, + locale: NS, label: () => t('view.trajectory'), inject: (sessionId: SessionId): TrajectoryViewInjected => { const history = ctx.sessionHistory.source(sessionId) @@ -44,7 +45,11 @@ export function apply(ctx: Context): void { exportLog: async () => { // The host streams the ZIP (root + descendant artifacts verbatim) // from GET /api/session.export; the browser downloads the response. - const url = new URL('/api/session.export', window.location.origin) + // A null origin (no-location Node contexts) falls back like the + // carrier's resolveBase so the URL stays valid. + const loc = (globalThis as { location?: { origin?: string } }).location + const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal' + const url = new URL('/api/session.export', origin) url.searchParams.set('sessionId', sessionId) url.searchParams.set('includeDescendants', 'true') const response = await fetch(url) @@ -52,12 +57,7 @@ export function apply(ctx: Context): void { const detail = await response.text().catch(() => '') throw new Error(`导出失败:HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } - const blob = await response.blob() - downloadBytes( - new Uint8Array(await blob.arrayBuffer()), - sessionLogZipFilename(sessionId), - 'application/zip', - ) + downloadBlob(await response.blob(), sessionLogZipFilename(sessionId)) }, } }, diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index f5660dfddf..aba527dd2b 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -1,14 +1,32 @@ -/** `trajectory` namespace dictionaries (the view tab label). */ +/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */ /** Dictionary namespace owned by this plugin. */ export const NS = 'trajectory' /** The trajectory dictionary key set (the source of truth for both locales). */ -export type TrajectoryKey = 'view.trajectory' +export type TrajectoryKey = + | 'view.trajectory' + | 'toolbar.aria' + | 'toolbar.duration' + | 'toolbar.useActualDuration' + | 'toolbar.useEqualWidth' + | 'toolbar.actualTime' + | 'toolbar.turns' + | 'toolbar.expandTurns' + | 'toolbar.collapseTurns' + | 'toolbar.calls' + | 'toolbar.expandCalls' + | 'toolbar.collapseCalls' + | 'toolbar.export' + | 'toolbar.exportAria' + | 'toolbar.exporting' + | 'toolbar.exportTitle' + | 'toolbar.search' + | 'toolbar.searchPlaceholder' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The trajectory view tab label. */ + /** The trajectory view tab label and toolbar strings. */ 'trajectory': TrajectoryKey } } @@ -16,9 +34,43 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh: Record = { 'view.trajectory': '轨迹', + 'toolbar.aria': '轨迹工具栏', + 'toolbar.duration': '时长', + 'toolbar.useActualDuration': '使用实际时长', + 'toolbar.useEqualWidth': '使用等宽时长', + 'toolbar.actualTime': '实际时间', + 'toolbar.turns': '轮次', + 'toolbar.expandTurns': '展开轮次', + 'toolbar.collapseTurns': '折叠轮次', + 'toolbar.calls': '调用', + 'toolbar.expandCalls': '展开调用', + 'toolbar.collapseCalls': '折叠调用', + 'toolbar.export': '导出', + 'toolbar.exportAria': '导出会话日志', + 'toolbar.exporting': '导出中…', + 'toolbar.exportTitle': '导出会话日志(ZIP,含子代理)', + 'toolbar.search': '搜索轨迹', + 'toolbar.searchPlaceholder': '搜索', } /** English dictionary. */ export const en: Record = { 'view.trajectory': 'Trajectory', + 'toolbar.aria': 'Trajectory toolbar', + 'toolbar.duration': 'Duration', + 'toolbar.useActualDuration': 'Use actual duration', + 'toolbar.useEqualWidth': 'Use equal-width operations', + 'toolbar.actualTime': 'Actual time', + 'toolbar.turns': 'Turns', + 'toolbar.expandTurns': 'Expand turns', + 'toolbar.collapseTurns': 'Collapse turns', + 'toolbar.calls': 'Calls', + 'toolbar.expandCalls': 'Expand calls', + 'toolbar.collapseCalls': 'Collapse calls', + 'toolbar.export': 'Export', + 'toolbar.exportAria': 'Export session log', + 'toolbar.exporting': 'Exporting…', + 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', + 'toolbar.search': 'Search trajectory', + 'toolbar.searchPlaceholder': 'Search', } diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.spec.ts index e693ded89b..ba7f739573 100644 --- a/packages/client/ui-trajectory/tests/export-log.spec.ts +++ b/packages/client/ui-trajectory/tests/export-log.spec.ts @@ -10,11 +10,15 @@ import { sessionLogZipFilename } from '../src/client/export-log.ts' describe('sessionLogZipFilename', () => { it('keeps safe session ids verbatim', () => { - expect(sessionLogZipFilename('session-abc_1.2')).toBe('dsh-session-session-abc_1.2.zip') + expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip') }) it('neutralizes unsafe id characters that could shape the filename', () => { - expect(sessionLogZipFilename('../evil')).toBe('dsh-session-.._evil.zip') + expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip') expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') }) + + it('strips dots so a dot-only id cannot shape a dot segment', () => { + expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip') + }) }) diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.spec.tsx index b3e2a4c014..820af51ec7 100644 --- a/packages/client/ui-trajectory/tests/toolbar.spec.tsx +++ b/packages/client/ui-trajectory/tests/toolbar.spec.tsx @@ -3,7 +3,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' + +/** Test translator pinned to the Simplified Chinese dictionary. */ +const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key afterEach(() => { cleanup() @@ -25,6 +30,7 @@ function baseProps(overrides: Partial = {}): TrajectoryT exporting: false, onExport: vi.fn(), exportError: null, + t: zhT, ...overrides, } } diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 9c52417b61..e76cc0f41b 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -28,6 +28,8 @@ import { import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -142,14 +144,18 @@ function emptyWorkspaces() { } /** Standalone view props: the session-scope standard kit the outlet would bake. */ -function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { +function standaloneProps( + nodes: ConversationSnapshot['nodes'], +): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { return { sessionId: SID, useSession: fakeSession(nodes).useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined) as never, - } as unknown as ConvViewProps + // The locale seat the outlet would inject for the declared namespace. + t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, + } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } } /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */ @@ -230,6 +236,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES exportLog: trajectory.exportLog, useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), + t: (key: TrajectoryKey) => zh[key], } })() : injected @@ -324,12 +331,12 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByText(/turns ·/)).toBeNull() expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) + fireEvent.click(screen.getByRole('button', { name: '折叠轮次' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) + fireEvent.click(screen.getByRole('button', { name: '展开轮次' })) expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() await vi.waitFor(() => { @@ -540,13 +547,13 @@ describe('tab switching in ConversationRoot', () => { const b = await bench(historySnapshot([])) mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.getByRole('button', { - name: 'Collapse turns', + name: '折叠轮次', }).disabled).toBe(false) expect(screen.getByRole('button', { - name: 'Collapse calls', + name: '折叠调用', }).disabled).toBe(false) expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull() @@ -1090,7 +1097,7 @@ describe('timeline projection', () => { ...standaloneExport(), }, )) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() }) }) @@ -1123,7 +1130,6 @@ describe('session log export', () => { expect(fetchMock).toHaveBeenCalledOnce() }) // The blob download lands a few microtasks after the fetch settles. - // The blob download lands a few microtasks after the fetch settles. await vi.waitFor(() => { expect(createObjectURL).toHaveBeenCalled() }) @@ -1159,7 +1165,7 @@ describe('TrajectoryView branches', () => { setActualDuration={(value) => { firstDuration.set(value) }} />, ) - const duration = screen.getByRole('button', { name: 'Use actual duration' }) + const duration = screen.getByRole('button', { name: '使用实际时长' }) expect(duration.getAttribute('aria-pressed')).toBe('false') fireEvent.click(duration) @@ -1175,7 +1181,7 @@ describe('TrajectoryView branches', () => { setActualDuration={(value) => { restoredDuration.set(value) }} />, ) - expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed')) + expect(screen.getByRole('button', { name: '使用实际时长' }).getAttribute('aria-pressed')) .toBe('true') }) From 2a34ca51a3e0809d758b1b30a8d090855703d38c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:32 +0800 Subject: [PATCH 26/67] test(web): assembled session-log download e2e and refreshed golden The navigation-panes replay scenario clicks the trajectory toolbar export button and asserts the real host-streamed download: filename, a single session.jsonl entry, and byte-verbatim seed content. The toolbar golden refreshes for the localized export button. --- apps/web/package.json | 3 ++- apps/web/tests/navigation-panes.e2e.ts | 18 ++++++++++++++++++ .../navigation-panes/trajectory.expected.md | 2 +- pnpm-lock.yaml | 3 +++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index c58e5b9682..66328783af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "playwright": "^1.49.0", "typescript": "^6.0.3", "vite": "^6.0.0", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "fflate": "^0.8.2" } } diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index b96a1fa393..c8b6455296 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' +import { strFromU8, unzipSync } from 'fflate' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -274,6 +275,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) + it.skipIf(MODE === 'record')('downloads the session-log ZIP from the trajectory toolbar', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) + await ensureSeedOpen(page) + await page.getByRole('tab', { name: 'Trajectory' }).click() + const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) + await page.getByRole('button', { name: 'Export session log' }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/) + // The real host streamed the ZIP; its root entry is the persisted log + // text verbatim (the assembled seam: real route, real persistence read). + const files = unzipSync(await readFile(await download.path())) + expect(Object.keys(files)).toEqual(['session.jsonl']) + const content = strFromU8(files['session.jsonl'] as Uint8Array) + expect(content.split('\n')[0]).toContain(SEED_ID) + expect(content).toContain('FIRST_DONE') + }, 60_000) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 8e6fa01d68..3476255bab 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -2,7 +2,7 @@ - button "Use actual duration": Duration - button "Collapse turns": Turns - button "Collapse calls": Calls - - button "导出会话日志": 导出 + - button "Export session log": Export - img - searchbox "Search trajectory" - region "Trajectory timeline": diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a69b80cee1..da0183895a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,6 +364,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.0.0 version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + fflate: + specifier: ^0.8.2 + version: 0.8.3 playwright: specifier: ^1.49.0 version: 1.61.1 From fbd09a70f3fb20fe1b2acc771ed4335fe921abda Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 19:50:43 +0800 Subject: [PATCH 27/67] docs: document SessionRawArtifact and fix backpressure claims The persistence subsystem page gains the SessionRawArtifact section (with a type-equiv manifest entry and zh counterpart), and the README and Agent Note describe the drain-based backpressure instead of claiming the archive never accumulates. --- ...2026-08-10-web-session-log-export.i18n.yaml | 4 ++-- .../2026-08-10-web-session-log-export.md | 4 ++-- .../2026-08-10-web-session-log-export.zh.md | 4 ++-- docs/subsystems/persistence.i18n.yaml | 4 ++-- docs/subsystems/persistence.md | 18 +++++++++++++++++- docs/subsystems/persistence.zh.md | 18 +++++++++++++++++- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../tool-cordis/src/api-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 5 +++++ 11 files changed, 52 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 60c70d5519..0c8a3f2781 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 9d31e4deb640474230dd9b16ec02f2f36dc5ca56 -2026-08-10-web-session-log-export.zh.md: 0a2ba678b0ea56202493507165d94750761ce3d9 +2026-08-10-web-session-log-export.md: 427b6478ac44fb28030aa932630f276de7bb2edc +2026-08-10-web-session-log-export.zh.md: 63b9804a54cda7eea4ff793d78a925fe296d06cb diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 9d31e4deb6..427b6478ac 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -1,4 +1,4 @@ -# Agent Note: Web session-log export via session.log + ZIP download +# Agent Note: Web session-log export as a host-streamed ZIP download Status: implemented @@ -10,7 +10,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision -- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never materializes the whole archive (at most one descendant's artifact text beyond the preloaded root). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. +- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. - **The UI just downloads**: the 导出 button fetches the endpoint and saves the response; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle no longer carries fflate (the earlier browser-entry-alias pitfall is moot). - The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button; a failure surfaces in a visible alert bar under the toolbar. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 0a2ba678b0..63b9804a54 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -1,4 +1,4 @@ -# Agent Note:Web 会话日志导出——session.log RPC + ZIP 下载 +# Agent Note:Web 会话日志导出——宿主流式 ZIP 下载 状态:implemented @@ -10,7 +10,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 -- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进内存(除预载的根外,最多同时持有一条后代的工件文本)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 - **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 - **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate(早先的浏览器入口别名坑随之消失)。 - 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示。 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index fa128a80d0..602ec01ec9 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.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/persistence.md -persistence.md: 25541139de02d2bd3ea743fe530e47a628002695 -persistence.zh.md: d76ff93f5564e7612fd7107cd74e3138c03542d8 +persistence.md: 363d28034b58d007d1440afcb8fb9137b0048091 +persistence.zh.md: e208720eae1902ad429fa358c112fe9188f8d8dd diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 25541139de..363d28034b 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -118,6 +118,22 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## `SessionRawArtifact` — verbatim stored artifact text + +A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives; backends without a per-session artifact, such as SQLite, inherit the `undefined` default. + +```ts type-equiv +/** A backend's own raw artifact text for one session, verbatim. */ +interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} +``` + ## Preparation and restoration ownership `SessionStore.prepare()` accepts ordinary creation options or fresh persistence graphs transferred through `RestoredSessionOptions`. The restoration branch validates and freezes the transferred header and events in place, so callers must retain no mutable aliases. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. Persistence inspection exposes only `SessionInspection`, an immutable logical view borrowed from the same prepared Session. @@ -250,7 +266,7 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined * @returns the raw artifact plus its parsed header, or `undefined` when the * session is absent or the backend owns no per-session artifact. */ -readRaw(_id: SessionId, signal?: AbortSignal): Promise +async readRaw(_id: SessionId, signal?: AbortSignal): Promise /** * Register a new session's metadata. A backend MAY defer the physical write diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index d76ff93f55..e208720eae 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -118,6 +118,22 @@ interface CreateSessionOptions { 因此,回放/fork 的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 +## `SessionRawArtifact`——逐字存储工件文本 + +后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留;没有每会话工件的后端(如 SQLite)继承 `undefined` 默认。 + +```ts type-equiv +/** A backend's own raw artifact text for one session, verbatim. */ +interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} +``` + ## 准备与恢复所有权 `SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的新鲜持久化对象图。恢复分支会直接验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。持久化检查只暴露 `SessionInspection`,即从同一个已准备 Session 借用的不可变逻辑视图。 @@ -250,7 +266,7 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined * @returns the raw artifact plus its parsed header, or `undefined` when the * session is absent or the backend owns no per-session artifact. */ -readRaw(_id: SessionId, signal?: AbortSignal): Promise +async readRaw(_id: SessionId, signal?: AbortSignal): Promise /** * Register a new session's metadata. A backend MAY defer the physical write diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 6a7abf8bdc..4a6976a6d6 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 452e157ed93ca4ace07a8c958bad04b15d598b31 -README.zh.md: f0509276ef690874e788a3a1e312d35ec37034f0 +README.md: 7d48fd9eaa5ce65a9b5ad22942fe82c2292bd710 +README.zh.md: 245ee6ed62231c9e4381405a8dd3c04428307061 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 452e157ed9..7d48fd9eaa 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never materializes the whole archive. It requires both the persistence and session-query services: a deployment without either answers 500, a missing root session 404, and a descendant without a stored artifact fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires both the persistence and session-query services: a deployment without either answers 500, a missing root session 404, and a descendant without a stored artifact fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f0509276ef..245ee6ed62 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进内存。它要求同时挂载持久化与 session-query 服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化与 session-query 服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 5f4d9a124b..6f9abb64da 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -689,7 +689,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Resolve this backend\'s independent local artifact for a session without\n * reading, creating, flushing, or otherwise materializing it. Backends such\n * as SQLite that do not own one artifact per session return `undefined`.\n * @param meta - the immutable session header whose artifact is requested.\n * @returns the backend-specific absolute location, when one exists.\n */', }, { - signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', + signature: 'async readRaw(_id: SessionId, signal?: AbortSignal): Promise', jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */', }, { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a9d998029..ef44631f07 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -440,6 +440,11 @@ "symbol": "SessionLocation", "source": "packages/session/session-persistence/src/index.ts" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionRawArtifact", + "source": "packages/session/session-persistence/src/index.ts" + }, { "doc": "docs/subsystems/session-query.md", "symbol": "SessionEventSurface", From fa2dce28ce2c6ea97465ba465bc4c471f3745d21 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 20:15:31 +0800 Subject: [PATCH 28/67] test(web): adapt ui-trajectory benches to the merged locale settings scope The locale plugin now derives its durable preference from a settings scope bound to the connection service, so the trajectory benches provide a connection handle before loading it. --- packages/client/ui-trajectory/tests/client-bundle.spec.ts | 4 +++- packages/client/ui-trajectory/tests/views.spec.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index ebe08ad920..f5a1c08960 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -75,8 +75,10 @@ describe('tsdown client artifact', () => { }, (_p: { renderSlot?: unknown }) => null) // The plugin reads sessionHistory for its per-session history source; // slot availability is tracked by slots.inject, and the locale plugin - // backs the locale-aware view tab label. + // backs the locale-aware view tab label (its settings scope needs a + // connection handle). ctx.provide('sessionHistory', {}) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const locale = await import('@deepseek-ai/dsh-client-locale/client') ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index e76cc0f41b..81fc9df818 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -181,7 +181,9 @@ async function bench(snapshot = historySnapshot(NODES)) { slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) ctx.provide('sessionHistory', { source: () => history }) - // The locale plugin backs the locale-aware view tab label ('locale' in inject). + // The locale plugin backs the locale-aware view tab label ('locale' in + // inject); its settings scope needs a connection handle. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.plugin({ inject: [...localeInject], apply: localeApply }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() From 2a9f56b0b783b5f90a4da27accbcd402eba315fb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 21:31:06 +0800 Subject: [PATCH 29/67] ci: retrigger windows self-hosted validation From 9d8c70ce58170efade17e3078de87f3cd6b4ab5f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 21:40:19 +0800 Subject: [PATCH 30/67] ci: fix mirror sync lock, retrigger validation From 72bdfeea608a2a1276d320136314b24aedef376a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 21:45:59 +0800 Subject: [PATCH 31/67] ci: unconditional mirror fetch in job hook From 5d8d79ce929dec262af124a819e68b9e99a6466a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 22:30:46 +0800 Subject: [PATCH 32/67] ci: unify Windows CI on native self-hosted runners Replace the Wine-emulated windows job and the independent windows-native job with a single required native Windows job that runs check:ci:windows-complete on the hosted larger runner, falling back to the self-hosted [self-hosted, dsh-win-ci, windows] pool under failover (DSH_CI_FAILOVER=selfhosted). The serial-windows standby mirrors serial-linux-selfhosted: master-only, unsharded, self-hosted, absent from all-checks-passed. Remove wine-apt-cache, the check:windows-wine script reference, and the dual-lane topology the two Windows notes described. --- .github/AGENTS.md | 2 +- .github/workflows/ci.yml | 177 ++++++++++----------------------------- package.json | 1 - 3 files changed, 46 insertions(+), 134 deletions(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index c18f5b5948..b2721fb84e 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` but reports independently — see the [dual-lane Agent Note](../.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md). +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The required pull-request `windows` job and the master `serial-windows` standby both run on real Windows; the self-hosted pool uses labels `[self-hosted, dsh-win-ci, windows]`. Under failover (`DSH_CI_FAILOVER=selfhosted`) the `windows` job retargets onto the self-hosted pool. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26a3d5be38..8a0482e103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,123 +313,18 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # The required pull-request Windows signal: the two blocking win32 surfaces - # (workspace build, production site) execute with real, checksum-verified - # Windows Node under Wine on standard hosted Linux. The independent - # windows-native job below keeps the complete native-kernel inventory — - # including the observational portability gates this lane does not run — - # on real windows-2025. This job only provisions runner state (caches, - # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same - # script the optional local gate `pnpm run check:windows-wine` runs. - # Current topology and fidelity limits live in - # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md + # Required pull-request Windows signal on real Windows. Under normal + # operation this runs on the hosted larger runner; under failover + # (DSH_CI_FAILOVER=selfhosted) it retargets onto the in-house self-hosted + # Windows pool. Dependabot PRs are excluded from the self-hosted pool and + # stay queued for the hosted runner — see the failover runbook. windows: if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - name: windows node 24 / wine blocking - timeout-minutes: 15 - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Configure pnpm store path - id: pnpm-store - run: | - store_root="$HOME/.local/share/pnpm/store" - echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" - store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) - echo "path=$store_path" >> "$GITHUB_OUTPUT" - - - uses: actions/cache/restore@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - # Master's wine-apt-cache job seeds the default-branch scope every pull - # request can read; a save from this job only reaches reruns of the - # same merge ref. - - name: Compose Wine apt cache key - id: wine-cache-key - run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ~/wine-debs - key: ${{ steps.wine-cache-key.outputs.key }} - - # Runner provisioning only — a developer machine installs Wine through - # its own package manager; the gate script assumes a wine binary and - # fails loud without one. Wine from the apt cache when present; else - # download the full dependency closure once and keep it for the next - # run. The `wine` dispatcher package (not bare `wine64`) is what puts a - # binary on PATH. - - name: Install Wine - run: | - if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then - sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb - else - sudo apt-get update - sudo apt-get install -y --no-install-recommends --download-only wine - mkdir -p "$HOME/wine-debs" - cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true - sudo apt-get install -y --no-install-recommends wine - fi - - - name: Run the Wine Windows gates - run: bash scripts/wine-windows-gates.sh - - - name: Shut down wineserver - if: always() - run: wineserver -k 2>/dev/null || true - - # Master seeds the Wine apt-archive cache in the default-branch scope, - # which every pull request's windows job can restore; saves from - # pull-request runs are scoped to their own merge ref and help nobody - # else. Runs in seconds when the image version already has a cache. - wine-apt-cache: - if: github.event_name == 'push' && github.ref == 'refs/heads/master' - name: wine apt cache - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Compose Wine apt cache key - id: wine-cache-key - run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - id: wine-cache - with: - path: ~/wine-debs - key: ${{ steps.wine-cache-key.outputs.key }} - - - name: Download the Wine dependency closure - if: steps.wine-cache.outputs.cache-hit != 'true' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends --download-only wine - mkdir -p "$HOME/wine-debs" - cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" - du -sh "$HOME/wine-debs" - - # Every pull request also gets a real Windows-kernel signal. This job keeps - # its own unmasked conclusion but is deliberately absent from - # all-checks-passed.needs, so it never delays or changes that required verdict. - # See the dual Wine/native pull-request CI decision: - # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md - windows-native: - if: github.event_name == 'pull_request' - runs-on: dsh-windows-2025-16core + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') + || 'dsh-windows-2025-16core' }} name: windows node 24 / native complete timeout-minutes: 60 env: @@ -455,8 +350,10 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - # Extracting the many-file pnpm store cache is slower than a clean install, - # and saving it adds more latency after the gates. + # Extracting the many-file pnpm store cache is slower than a clean + # install on hosted Windows runners, and saving it adds latency after + # the gates. The self-hosted VM's persistent store makes caching + # redundant. - name: Install (immutable) shell: pwsh run: pnpm install --frozen-lockfile @@ -621,14 +518,22 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + # Hot-standby drill for the in-house self-hosted Windows pool: every master + # move re-runs the complete unsharded Windows gate inventory on the persistent + # VM, continuously proving that environment can take over the required + # `windows` lane if the hosted pool degrades (the switch is setting the + # writer-manageable DSH_CI_FAILOVER variable — see the failover runbook, no + # merge required). Push-triggered, so this lane always executes the base + # branch's own workflow definition. Non-blocking for pull requests; absent + # from all-checks-passed.needs by design — the required `windows` job owns + # the PR verdict. No cache steps because the VM's persistent pnpm store + # and tool caches make them redundant (and saving here would poison the + # hosted cache namespace with self-hosted paths). serial-windows: - # Real-Windows signal on the in-house self-hosted Windows pool. This lane - # runs the complete primary Node CI on actual Windows (in contrast to the - # Wine-emulated `windows` job), so win32-native output is exercised where - # it matters. Selector matches our self-hosted runners by label. - if: github.event_name == 'pull_request' - name: serial / windows + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: serial / windows (self-hosted standby) runs-on: [self-hosted, dsh-win-ci, windows] + timeout-minutes: 60 steps: - uses: actions/checkout@v6 @@ -646,20 +551,30 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure persistent pnpm store + shell: pwsh + run: | + $storeRoot = "$env:LOCALAPPDATA\pnpm\store" + echo "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV + - name: Install (immutable) shell: pwsh run: pnpm install --frozen-lockfile - - name: Run complete unsharded primary Node CI serially + # The persistent VM image owns Playwright's Windows system dependencies; + # this step also proves that browser provisioning remains usable for + # failover. + - name: Install Playwright Chromium + shell: pwsh + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium + + - name: Run complete unsharded Windows gate inventory serially shell: pwsh env: DSH_COVERAGE_MAX_WORKERS: '1' - DSH_E2E_MAX_WORKERS: '1' DSH_GATE_CONCURRENCY: '1' - DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' - DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci + run: pnpm run check:ci:windows-complete # Manual, bounded comparison of the actual critical Linux and Windows lanes. # The named pools are restricted at the organization level to this repository. @@ -870,9 +785,7 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`. The required Wine job is listed as `windows`; `windows-native` is - # deliberately absent so its independent result never delays or changes this - # verdict. (`needs` cannot reach across workflow files; e2e.yml stays its own + # `needs`. (`needs` cannot reach across workflow files; e2e.yml stays its own # check.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing @@ -889,7 +802,7 @@ jobs: && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} - needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows, serial-windows] + needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows] if: always() && github.event_name == 'pull_request' steps: - name: Fail if any needed job did not succeed diff --git a/package.json b/package.json index d417354739..9c4d98f0c3 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", - "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", From 1cfbafab6a09c6ded171dd5d384a5a8c641bafc4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 22:36:06 +0800 Subject: [PATCH 33/67] fix(preset): keep the token meter host-plane and name unjoined agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the agent plane behind presets left two readers on the wrong side of the host/agent line. `dsh-token-meter` was disabled on the host and mounted inside each preset's `compaction` realm, but its three projection units register into the process-wide `sessionProjections` table. A unit registered from one preset answers for every session, so whether a `minimal` session showed a context meter depended on whether some other session had mounted `standard` since boot, and a process that only ever ran `minimal` showed none. The meter takes no configuration, keys every fold by Session, and registers no tool or prompt section, so it returns to the host composition and leaves the presets' `isolate` map; the realm and `compact-basic` stay, because what a preset chooses is whether its agent compacts, not whether its tokens are counted. Nothing named an agent that joined no preset. The join is a scope-parent link, and without it the tools, prompt-section, and skill views resolve the empty global layer: the agent publishes, the turn runs, and the model receives nothing. `AgentPresets` now logs one warning per such agent while a roster is configured, and the invariant companion fails outright — at `system-prompt/assemble` rather than at publication, because an unjoined agent is legal until it addresses a model and `recompose` binds exactly such an agent. The warning stays advisory: a synchronous `agent/created` throw vetoes publication, and the ACP bridge, SDK server, and headless bundle all create an unjoined agent today. Three limits are recorded rather than fixed: projection key presence is not a per-session capability signal, a superseded standing generation is never reclaimed, and a `cordis_mount` temporary plugin belongs to the composition rather than the session that mounted it. Fixes #2203 --- ...st-plane-ownership-after-presets.i18n.yaml | 6 +++ ...8-10-host-plane-ownership-after-presets.md | 45 +++++++++++++++++++ ...0-host-plane-ownership-after-presets.zh.md | 45 +++++++++++++++++++ .../agent-presets/code/agent.cordis.yml | 11 +++-- .../agent-presets/cordis/agent.cordis.yml | 11 +++-- .../agent-presets/minimal/agent.cordis.yml | 4 +- .../agent-presets/standard/agent.cordis.yml | 11 +++-- apps/cli/package.json | 1 + apps/cli/tests/web-agent-presets.e2e.ts | 28 ++++++++++++ apps/web/tests/seeded-history.e2e.ts | 21 +++------ 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 | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 34 +++++++------- docs/module-graph.zh.md | 34 +++++++------- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 10 +++-- docs/subsystems/tools.zh.md | 10 +++-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- packages/acp/acp/src/index.ts | 6 +++ packages/bundle/headless/src/index.ts | 7 +++ packages/bundle/web-app/cordis.patch.yml | 13 +++++- packages/core/agent-tool-mode/src/index.ts | 10 +++-- packages/core/tools/src/index.ts | 18 ++++---- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 3 +- packages/preset/agent-presets/README.zh.md | 3 +- packages/preset/agent-presets/package.json | 2 + packages/preset/agent-presets/src/index.ts | 35 ++++++++++++++- .../preset/agent-presets/src/invariant.ts | 33 +++++++++++++- .../agent-presets/tests/invariant.spec.ts | 24 ++++++++++ .../preset/agent-presets/tests/mount.spec.ts | 16 +++++++ packages/preset/agent-presets/tsconfig.json | 6 +++ packages/scaffold/server/src/server.ts | 6 +++ .../tool-cordis/README.i18n.yaml | 4 +- .../self-modification/tool-cordis/README.md | 1 + .../tool-cordis/README.zh.md | 1 + .../tool-cordis/src/api-catalog.ts | 2 +- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 1 + .../session/session-projection/README.zh.md | 1 + pnpm-lock.yaml | 3 ++ 49 files changed, 398 insertions(+), 114 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml new file mode 100644 index 0000000000..da6e264f1b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md +2026-08-10-host-plane-ownership-after-presets.md: 5b0a340e875005182a0e6cd0f880b34b14258fb2 +2026-08-10-host-plane-ownership-after-presets.zh.md: 4b1e04f924e656b0f6ad4d070a3b77ce0189c608 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md new file mode 100644 index 0000000000..5b0a340e87 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md @@ -0,0 +1,45 @@ +# Agent Note: What stays host-plane once presets own the agent plane + +Status: implemented + +English | [中文](2026-08-10-host-plane-ownership-after-presets.zh.md) + +## Problem + +[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)). + +Two more readers were still on the wrong side of that line. + +`dsh-token-meter` was disabled on the host and mounted inside each preset's `compaction` realm. It takes no configuration, keys every fold by `Session`, and registers no tool or prompt section — but it owns the `tokenUsage`, `contextPressure`, and `contextBreakdown` projection units, and `sessionProjections` is a process-wide table with no scope layering. A unit registered from inside one preset therefore answers for every session: whether a `minimal` session showed a context meter depended on whether some *other* session had mounted `standard` since boot, and a process that only ever ran `minimal` showed none at all. + +Nothing named an agent that joined no preset. The join is a scope-parent link; without it the `tools`, `system-prompt`, and `skill` views resolve the empty global layer and the model receives nothing — no error, no empty catalog, just an agent that cannot act. That is how delegated subagents ran for as long as presets existed, and the same hole is open at every entry point that predates them. + +## Decision + +**The meter is host-plane.** `dsh-token-meter` returns to the host composition and leaves the presets' `isolate` map, so `compact-basic` and `tool-result-prune` resolve the one host instance from inside their realm. The presets keep the realm and the backend — what a preset chooses is whether its agent compacts, not whether its tokens are counted. This is the criterion `tasks` and `goals` are already read by, applied to a Service whose *projection* reach is what made preset ownership wrong: a unit whose empty value is indistinguishable from a real one cannot be per-composition while the table it registers into is per-process. + +**An unjoined agent is named twice, at two different points.** `AgentPresets` logs one warning per agent published with a scope chain of length one while a roster is configured. The invariant companion fails instead — and at `system-prompt/assemble`, not at publication, because an unjoined agent is legal until it addresses a model: `recompose` binds exactly such an agent as its first link, and prompt assembly is the only caller that supplies an agent scope, so a host assembly and a standing mount are both correctly out of range. + +Three limits stay open and are recorded where they bite rather than fixed here: projection key presence is not a per-session capability signal ([`dsh-session-projection`](../../../../packages/session/session-projection/README.md)); a superseded standing generation is never reclaimed, which the settings-page authoring flow turns into a per-save cost ([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md)); and a temporary plugin mounted through `cordis_mount` belongs to the composition rather than the session that mounted it ([`dsh-tool-cordis`](../../../../packages/self-modification/tool-cordis/README.md)). + +## Testing + +`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any preset in the file mounts — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units. + +`packages/preset/agent-presets/tests/mount.spec.ts` asserts the warning fires exactly once for a bare agent and not at all for a joined one. `tests/invariant.spec.ts` carries the negative control: an unjoined agent's assembly rejects, while a joined agent's assembly and a scopeless host assembly both pass. + +## Alternatives considered + +**Keep the meter in the preset and scope-layer the projection registry.** The precise fix, and much larger: `snapshot`, `checkpoint`, and the eager drive would each need a session→scope resolution that a cold read does not have without the api-proxy's `presenterScopeFor`. Rejected as disproportionate to one Service with no per-preset state at all; the general rule is documented on the registry instead. + +**Veto publication for an unjoined agent.** Loud beats silent, and the registry supports it — a synchronous `agent/created` listener that throws rolls the creation back. Rejected because composing an agent outside the roster is legal: `recompose` documents the bare agent it then binds, and the ACP bridge, the SDK server, and the headless bundle all create one today. A veto would convert a capability gap into an outage. + +**Check the join at `agent/created` in the companion too.** Rejected: publication cannot distinguish a missed join from an agent that will be bound later, so the check would reject a documented path. Prompt assembly can distinguish them. + +**Move `plan-mode` and `tool-todo` off the agent plane for the same projection reason.** Rejected: both are genuinely per-preset capabilities, and their units compute an empty value for a session that never uses them, which clients already read by value (`plan.active`, an empty list). Only a unit whose empty value is indistinguishable from a real one — the meter — forces host ownership. + +## Consequences + +The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting. + +The warning is advisory, so a deployment that adds a roster to the ACP or SDK-server entry points still starts agents with no tools — it just says so once per agent instead of silently. The invariant reaches only compositions that load `dsh-invariants`, which fences package tests and development hosts, not a shipped one. diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md new file mode 100644 index 0000000000..4b1e04f924 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md @@ -0,0 +1,45 @@ +# Agent Note: What stays host-plane once presets own the agent plane + +Status: implemented + +[English](2026-08-10-host-plane-ownership-after-presets.md) | 中文 + +## Problem + +[逐会话 agent preset](2026-08-03-per-session-agent-presets.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md))。 + +还有两个读取点仍站在这条线的错误一侧。 + +`dsh-token-meter` 在宿主侧被禁用,改挂进每个 preset 的 `compaction` realm。它不接受任何配置,每次折叠都以 `Session` 建键,也不注册工具或提示段——但它拥有 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 三个投影单元,而 `sessionProjections` 是一张进程级、没有作用域分层的表。因此从某个 preset 内部注册的单元会替所有会话作答:一个 `minimal` 会话是否显示 context meter,取决于本次启动以来有没有**别的**会话挂过 `standard`;而只跑过 `minimal` 的进程根本不显示。 + +没有加入任何 preset 的 agent 也无人指出。加入是一条 scope 父链链接;缺了它,`tools`、`system-prompt` 与 `skill` 的视图都解析到空的全局层,模型什么也收不到——不报错,也没有空目录可看,只是一个无法行动的 agent。被委派的子 agent 在 preset 存在的整段时间里都是这样运行的,而同一个洞在每一个早于 preset 的入口点上都开着。 + +## Decision + +**meter 属于宿主平面。** `dsh-token-meter` 回到宿主组装,并离开各 preset 的 `isolate` 映射,于是 `compact-basic` 与 `tool-result-prune` 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 `tasks` 与 `goals` 已经采用的判据,只是这次适用于一个因**投影**触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。 + +**未加入的 agent 在两个不同的点上被指出两次。** 在配置了名单的前提下,`AgentPresets` 对每个作用域链长度为一就发布的 agent 记录一条警告。invariant 配套则直接失败——并且发生在 `system-prompt/assemble` 而非发布时,因为一个未加入的 agent 在它对模型说话之前都是合法的:`recompose` 绑定的正是这样一个 agent 作为它的首次链接;而提示词组装是唯一会提供 agent 作用域的调用方,因此宿主组装与常驻挂载都正确地落在检查范围之外。 + +有三处限制不在此处修复,而是记录在会咬到它们的地方:投影 key 是否存在不能当作逐会话的能力信号([`dsh-session-projection`](../../../../packages/session/session-projection/README.md));被替代的常驻代际永不回收,而设置页的编写流程把它变成每次保存的代价([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md));通过 `cordis_mount` 挂上的临时插件属于组装而非挂载它的会话([`dsh-tool-cordis`](../../../../packages/self-modification/tool-cordis/README.md))。 + +## Testing + +`apps/cli/tests/web-agent-presets.e2e.ts` 在本文件中任何 preset 挂载**之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。 + +`packages/preset/agent-presets/tests/mount.spec.ts` 断言警告对裸 agent 恰好触发一次、对已加入的 agent 完全不触发。`tests/invariant.spec.ts` 承担负控:未加入 agent 的组装被拒绝,而已加入 agent 的组装与不带作用域的宿主组装都通过。 + +## Alternatives considered + +**把 meter 留在 preset,改为给投影注册表分层。** 这是更精确的修法,代价也大得多:`snapshot`、`checkpoint` 与主动驱动都需要一次「会话 → 作用域」的解析,而冷读在没有 api-proxy 的 `presenterScopeFor` 时并不具备。相对于一个完全没有 per-preset 状态的 Service,这不成比例,因此改为把通则写在注册表上。 + +**对未加入的 agent 否决发布。** 大声胜过静默,注册表也支持这么做——同步的 `agent/created` 监听器抛出会把创建整体回滚。否决的理由是:在名单之外组装 agent 是合法的——`recompose` 写明了它随后绑定的那个裸 agent,而 ACP 桥、SDK server 与 headless bundle 今天都会创建一个。否决会把能力缺口变成一次故障。 + +**让配套也在 `agent/created` 处检查加入情况。** 否决:发布时分不清漏掉的加入与之后才会被绑定的 agent,因此该检查会拒绝一条已写明的路径。提示词组装分得清。 + +**基于同样的投影理由,把 `plan-mode` 与 `tool-todo` 也搬离 agent 平面。** 否决:两者确实是逐 preset 的能力,且对从不使用它们的会话,其单元算出的就是空值,而客户端本来就按值读取(`plan.active`、空列表)。只有空值与真实值无法区分的单元——meter——才被迫归宿主所有。 + +## Consequences + +context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。 + +那条警告是建议性的,因此给 ACP 或 SDK server 入口加上名单的部署依然会启动没有工具的 agent——只是每个 agent 会说一次,而不再静默。invariant 只触达装载了 `dsh-invariants` 的组装,因此它把关的是包测试与开发宿主,不是随附宿主。 diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 5e273bb955..185254224c 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -134,17 +134,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 1bc3d3bcf0..a32fd3dbe0 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -115,17 +115,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 6ae88b9339..2a682375d7 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -7,7 +7,9 @@ # from a shared default. # # The host composition is unchanged: this agent still runs inside the same -# sandbox, approval, persistence, and model routing as any other session. +# sandbox, approval, persistence, and model routing as any other session — and +# the same token meter, so the browser's context meter reads this session like +# any other. What this preset drops is auto-compaction, not the accounting. - id: persona name: '@deepseek-ai/dsh-persona' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 6442b12306..c5b9518400 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -127,17 +127,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/apps/cli/package.json b/apps/cli/package.json index 03bd4c3db9..1668ee8bcc 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 004de203c8..70bbe66cc6 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -15,6 +15,9 @@ import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subage import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' +// Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`. +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-token-meter' const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) @@ -113,6 +116,31 @@ describe('the shipped Web composition', () => { expect(toolNames(ctx)).toEqual([]) }) + it('keeps the token meter and its context-meter projections on the host plane', async () => { + // Read before any preset in this file mounts, which is what makes this an + // ownership assertion rather than a mount-order coincidence: a preset-side + // meter sits behind an `isolate` realm and is invisible to `ctx.get`. + // + // The projection registry is process-wide rather than scope-layered, so a + // preset-side meter would also make the browser's context meter appear for + // a `minimal` session the moment some OTHER session mounted a preset that + // carries one, and vanish entirely in a process that only ever ran + // `minimal`. Host ownership is what makes the meter a per-session fact. + expect(ctx.get('tokenMeter')).toBeDefined() + const projections = ctx.get('sessionProjections') + if (projections === undefined) throw new Error('the Web composition must compose a projection registry') + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-meter'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + expect(Object.keys(projections.snapshot(handle.agent.session).values).sort()) + .toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage'])) + } finally { + await handle.dispose() + } + }) + it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index a112933f9c..82a8fcf58a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -18,7 +18,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-agent-presets' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' import { join } from 'node:path' @@ -195,21 +194,11 @@ describe('web e2e: seeded history renders through cold resume', () => { if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) - // The meter belongs to an agent's preset, not to the process — token - // accounting is per session. It is used here as a pure pricing function - // over fixture content, so a throwaway composition is enough to reach one. - const priced = await scaffold.ctx.agents.create({ - sessionId: SessionId('seeded-history-pricing'), - setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined), - }) - let realizedWithCompaction: string - try { - const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter') - if (meter === undefined) throw new Error('seeded-history requires the composed token meter') - realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) - } finally { - await priced.dispose() - } + // The meter is host-plane — it takes no configuration and keys every + // fold by Session — so pricing fixture content needs no agent at all. + const meter = scaffold.ctx.get('tokenMeter') + if (meter === undefined) throw new Error('seeded-history requires the host token meter') + const realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) await seedSession(scaffold, realizedWithCompaction, SEED_ID) } browser = await chromium.launch() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 889b2e24e7..400911a03a 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: 471680f92dc44f3dd4e98ba9e946525ec79f25b0 -config-catalog.zh.md: bf78766799b02a1f6f21f935723abace108bc306 +config-catalog.md: d2db35d5ad50025975e0cbfe2275b752151c08c4 +config-catalog.zh.md: 056309751a677027c289ad14af27a6e88999025f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 471680f92d..d2db35d5ad 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -259,7 +259,7 @@ export interface Config { Depends on: [`ToolPresentationMode`](subsystems/tools.md) -Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) +Source: [`packages/core/agent-tool-mode/src/index.ts:38`](../packages/core/agent-tool-mode/src/index.ts) ## `@deepseek-ai/dsh-attachment-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bf78766799..056309751a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -261,7 +261,7 @@ export interface Config { 依赖:[`ToolPresentationMode`](subsystems/tools.md) -来源:[`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) +来源:[`packages/core/agent-tool-mode/src/index.ts:38`](../packages/core/agent-tool-mode/src/index.ts) ## `@deepseek-ai/dsh-attachment-local` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 268fa1c8d0..f351e7c078 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: d2e5cd66c2406cf84320fc1c0537d580214dd93d -event-producer-consumer.zh.md: 12ac194d68ed6121cc0b2c5e9bf2c7f77944f57b +event-producer-consumer.md: d27dfcc7b05f590d7117c650b79841ba2e00dbfe +event-producer-consumer.zh.md: e49e806b3a7dded440e6ffcfbe23d9548c803374 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d2e5cd66c2..d27dfcc7b0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | @@ -41,7 +41,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 12ac194d68..e49e806b3a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | @@ -43,7 +43,7 @@ | `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`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 77fcfca867..34a297395b 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: b596cb564943e321ff42cd59855bd7dc10981319 -module-graph.zh.md: b64f30c2df1eedb1a1fa106b79710558b765fdde +module-graph.md: c3e9334f5d993e7cb1d5d032a1df7fca28e0928e +module-graph.zh.md: 89b7f921fc7f54226f2435c7d0afc08c61fe9013 diff --git a/docs/module-graph.md b/docs/module-graph.md index b596cb5649..c3e9334f5d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -515,12 +515,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_client_ui_slots pkg_host_directory_picker_native --> pkg_client_ui_workspace pkg_host_directory_picker_native --> pkg_invariants - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_paths - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -572,8 +566,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver @@ -593,6 +585,14 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -702,11 +702,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants @@ -718,6 +713,8 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_user_id + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -889,6 +886,11 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1324,7 +1326,6 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1338,11 +1339,11 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1369,10 +1370,10 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1401,6 +1402,7 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b64f30c2df..89b7f921fc 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -517,12 +517,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_client_ui_slots pkg_host_directory_picker_native --> pkg_client_ui_workspace pkg_host_directory_picker_native --> pkg_invariants - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_paths - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -574,8 +568,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver @@ -595,6 +587,14 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -704,11 +704,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants @@ -720,6 +715,8 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_user_id + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -891,6 +888,11 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1326,7 +1328,6 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1340,11 +1341,11 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1371,10 +1372,10 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1403,6 +1404,7 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index a7d26cee1b..0123946525 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: ad00c4da7d77b0e1ab4728173b202ebc17fb56a0 -core.zh.md: 9c606023c85369643e7148f829526b1f75ea3631 +core.md: 96655026f5affda6fed080496d975e2366f0356f +core.zh.md: e2cde8845ddf6b78f64d062fd8860c0c88b7ce11 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index ad00c4da7d..96655026f5 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 9c606023c8..e2cde8845d 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index fbf617f20a..90ee0c989f 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 6ff2d967c5631d096dd78236ffd0383ddb2b0493 -tools.zh.md: 82ade5d8d4117387138296cf54fbc8e88ad335e7 +tools.md: 4e56d420f9ba9541725e41e4da87846654206119 +tools.zh.md: f6eee0f0f9b3c549679cc4437755c749caf68c9c diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 6ff2d967c5..4e56d420f9 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -480,12 +480,14 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v ```ts cordis-catalog /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 82ade5d8d4..f6eee0f0f9 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -480,12 +480,14 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v ```ts cordis-catalog /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6fa938c18e..f9828f9f13 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present the calling scope's tools in `mode` instead of the deployment\n * default. Nearest scope on the chain wins, so a preset's standing\n * declaration covers every agent joined under it.\n *\n * Scoped only, and one declaration per scope: this is how an agent preset\n * composes Code Mode agents beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation the covered agents' models see.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index ce00fb105a..437a0926ed 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -252,6 +252,12 @@ export function apply(ctx: Context, config: AcpConfig): void { assertOpen() validateSessionParams(params) const sessionId = SessionId(randomUUID()) + // No preset composition: the ACP bundle keeps the model-facing rows in + // the host plane, so this agent reads them from the global registry + // layer. A deployment that configures `dsh-agent-presets` has to join + // one here first — registration visibility inherits only along the + // `dsh-scope` parent chain, and an agent created without that link + // reads an empty global layer and reaches the model with no tools. const handle = await agents.create({ sessionId, meta: { cwd: params.cwd }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 2ea68c441b..ad3a41c203 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -106,6 +106,13 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { if (agents === undefined || defaultModel === undefined || sessions === undefined) return const selection = defaultModel.currentSelection() + // This bundle composes no preset roster, so the model-facing rows sit in the + // host plane and the agent reads them from the global registry layer. A + // deployment that DOES configure `dsh-agent-presets` has to join one here + // first: registration visibility inherits only along the `dsh-scope` parent + // chain, and `AgentPresets.mount()` is the only thing that links an agent to + // it, so an agent created without that link reads an empty global layer and + // reaches the model with no tools at all. const { agent } = await agents.create({ sessionId: SessionId(`session-${randomUUID()}`), meta: { cwd: process.cwd() }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 3e88c4d6ca..c3ec7844b9 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -270,8 +270,17 @@ - id: plan-mode disabled: true -- id: token-meter - disabled: true +# The token METER stays on the host plane; only the compaction backend that +# reads it moves. It takes no configuration at all, keys every fold by Session, +# and registers no tool or prompt section, so one host instance serves every +# preset exactly as before. It also owns the `tokenUsage`/`contextPressure`/ +# `contextBreakdown` projection units, and the projection registry is +# process-wide rather than scope-layered: registered from inside a preset, the +# browser's context meter would appear for a session whose own composition +# mounts no meter as soon as some OTHER session mounted one, and disappear +# entirely in a process that only ever ran `minimal`. That is the same criterion +# `tasks` and `goals` are read by — a Service the host and every preset can see +# belongs to the plane both can see. - id: compact-basic disabled: true diff --git a/packages/core/agent-tool-mode/src/index.ts b/packages/core/agent-tool-mode/src/index.ts index d2f1e8fd49..75ff10b29a 100644 --- a/packages/core/agent-tool-mode/src/index.ts +++ b/packages/core/agent-tool-mode/src/index.ts @@ -5,8 +5,10 @@ * The tool registry itself stays on the host plane — the agent loop's * scheduler, the API proxy's presenters, and every tool plugin are all its * consumers, so it cannot move into a preset. What a preset CAN own is the - * presentation: `ctx.tools.presentAs()` declares it for the mounting agent - * alone, so a Code Mode agent runs beside native ones in one process. + * presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE, + * which is the preset's standing mount, so the declaration covers every agent + * joined to that preset and a Code Mode preset runs beside native ones in one + * process. One row per composition, not one per session. * * A code mode needs a TypeScript code runtime, which is a host-plane service * ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)). @@ -50,8 +52,8 @@ export const Config: z = z.object({ }) /** - * Declare this agent's tool presentation. - * @param ctx - the mounting agent's scope context. + * Declare the tool presentation for every agent this composition covers. + * @param ctx - the mounting composition's scope context (a preset's standing scope). * @param config - the selected presentation. */ export function apply(ctx: Context, config: Config): void { diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 86c69d9307..547eb19f56 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -786,7 +786,7 @@ export class ToolRegistry extends Service { scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }, ) - /** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */ + /** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */ private readonly defaultMode: ToolPresentationMode private readonly maxParallelSubCalls: number /** @@ -811,7 +811,7 @@ export class ToolRegistry extends Service { /** * The generated-SDK prompt section, registered globally by a code-mode - * deployment and per agent by {@link presentAs}. + * deployment and per scope by {@link presentAs}. * * The body regenerates from the CALLING scope, and renders empty for an * agent presenting natively — an agent that opted out under a code-mode @@ -880,12 +880,14 @@ export class ToolRegistry extends Service { } /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void { @@ -898,14 +900,14 @@ export class ToolRegistry extends Service { ctx, (layer) => { if (layer.mode !== undefined) { - throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`) + throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`) } layer.mode = mode return () => { layer.mode = undefined } }, { label: 'tools.presentAs()' }, ) - // The SDK section is per agent for the same reason the mode is. Under a + // The SDK section is per scope for the same reason the mode is. Under a // deployment that already defaults to a code mode this shadows the // global registration with an identical body, which costs nothing and // keeps one rule instead of a case analysis. diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index ecd2a5a6c0..5d1f4fff96 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 632fc7828313a932512cb59a924050005067a008 -README.zh.md: 41dfab1af81149a221cb333a5613ab0a2d899899 +README.md: 2c9a16081e93895e40a60378376d0db06e271913 +README.zh.md: 2598b35f5494f6c56dfc9f1ba08a6784a5087cb6 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 632fc78283..2c9a16081e 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -133,7 +133,8 @@ Prefix-stable for the life of an agent: a composition is installed once, before ## Known Limitations and Deferred Work - **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards. -- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions). +- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. +- **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`. - **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start. - **Health is a shape check, not a mount** — discovery proves the composition parses in the loader dialect and holds named rows, not that every row's module resolves or activates; a row naming an absent package still fails at the first session, which rolls the creation back. - **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 41dfab1af8..2598b35f54 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -133,7 +133,8 @@ Indirectly, through the plugins a standing composition registers, which own ever ## Known Limitations and Deferred Work - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 -- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。 +- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 +- **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。 - **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。 - **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。 - **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index c012dc3eda..2da791cffe 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -27,12 +27,14 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 1dde902234..1ed8fdbdc8 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -24,7 +24,9 @@ import { stat } from 'node:fs/promises' import { Context, Service } from 'cordis' import z from 'schemastery' -import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeChainOf, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +// Type-only: resolves the `agent/created` lifecycle event this service watches. +import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' @@ -130,6 +132,31 @@ export class AgentPresets extends Service { this.settingsService = undefined }, 'agentPresets.settings()') }) + + // An agent joins a preset by having its scope key parented to a standing + // mount, and `mount`/`composeFrom` are the only things in the runtime that + // install that link. An agent that never joined keeps a chain of length + // one, so its `tools`, `system-prompt`, and `skill` views resolve against + // the EMPTY global layer and the model simply has nothing — no error, no + // empty catalog to notice, just an agent that cannot act. + // + // Advisory rather than fatal, and deliberately not the same observation the + // invariant companion makes. A synchronous `agent/created` listener that + // throws VETOES publication, and this service must not: composing an agent + // outside the roster is legal (`recompose` documents the bare agent it then + // binds, and entry points that predate presets still create one), so + // vetoing would turn a capability gap into an outage. The companion fails + // loud instead, at the later point where the empty world reaches a model. + ctx.on('agent/created', ({ agent }) => { + if (this.config.roots.length === 0) return + const key = scopeOf(agent.ctx) + if (key !== undefined && scopeChainOf(key).length > 1) return + ctx.logger.warn( + `agent "${agent.id}" was published without joining an agent preset; ` + + 'its tools, prompt sections, and skill catalog resolve against the empty global layer ' + + '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)', + ) + }) } /** @@ -440,6 +467,12 @@ export class AgentPresets extends Service { // disappearing, and failing the session over a stat would not. const current = await compositionStamp(preset.path) if (current === undefined || sameStamp(mounted.stamp, current)) return mounted + // TODO: reclaim the superseded generation once the last agent joined to + // it is gone. The subtree is not inert — `dsh-skill-local` watches its + // roots — and the settings-page authoring flow turns "a composition + // changed" into a per-save event. This needs a joined-agent count on + // StandingMount, incremented in `mount`/`composeFrom`/`recompose` and + // decremented when the agent's scope key dies. // Guarded delete: a caller that raced this one may have already started // the next generation, and dropping THAT pointer would fork a third. if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id) diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index 7a08eab7f1..dd80320a5a 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -5,6 +5,9 @@ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { scopeChainOf } from '@deepseek-ai/dsh-scope' +// Type-only: resolves the `system-prompt/assemble` waterfall this companion joins. +import type {} from '@deepseek-ai/dsh-system-prompt' // Imported through the package name, not `./mount.ts`: a module shared between // the two build entry points becomes a third chunk that the published `files` // list does not carry, which `verify-built-package-invariants` rejects. @@ -18,9 +21,10 @@ export const name = 'agent-presets-invariant' export const inject = ['invariants'] /** - * Assert that no installed preset composition reaches the root service realm. + * Assert that no installed preset composition reaches the root service realm, + * and that a deployment configuring a roster composes every agent from it. * - * `mountPreset` proves this once, when the subtree settles. A row that + * `mountPreset` proves the first once, when the subtree settles. A row that * publishes later — from a timer, or an asynchronous continuation after its * plugin returned — would escape that one-shot audit, so re-check every live * mount whenever a service registration changes. @@ -37,6 +41,31 @@ const install: InvariantInstaller = (ctx, fail) => { ) } }, { global: true }) + + // The join is a scope-parent link, and `AgentPresets.mount()` is the only + // thing in the runtime that installs one. An agent minted without it keeps a + // chain of length one, so its `tools`, `system-prompt`, and `skill` views + // fall back to the empty global layer and the model receives nothing. + // + // Checked at ASSEMBLY, not at publication: an unjoined agent is legal until + // it addresses a model — `recompose` binds a bare agent as its first link, + // and that agent is unjoined for its whole life up to the switch. Assembling + // a prompt is the point where the empty world stops being a state and + // becomes what the model sees, and it is the only caller that supplies an + // agent scope, so a host assembly (no scope) and a standing mount are both + // correctly out of range. + ctx.on('system-prompt/assemble', (_assembly, context, next) => { + const presets = ctx.get('agentPresets') + const scope = context.scope + if (presets !== undefined && presets.config.roots.length > 0 + && scope !== undefined && scopeChainOf(scope).length === 1) { + fail( + 'an agent addressed a model without joining any agent preset while a roster is composed; ' + + 'its tools, prompt sections, and skill catalog resolve against the empty global layer', + ) + } + return next() + }) } /** diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 17d89813c3..5577962a1d 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -84,4 +84,28 @@ describe('agent-presets invariants', () => { setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'), })).resolves.toBeDefined() }) + + it('rejects an agent that addresses a model without joining any preset', async () => { + const ctx = await harness() + // The delegation shape: an agent composed outside the roster keeps a + // scope chain of length one, so every registry view it reads is the empty + // global layer. Publication alone stays legal — `recompose` binds exactly + // such an agent — so nothing fires until that empty world reaches a prompt. + const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') }) + + await expect(ctx.systemPrompt.assemble({ scope: handle.agent })) + .rejects.toThrow(/without joining any agent preset/) + }) + + it('admits a joined agent and a host assembly that names no scope', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + sessionId: SessionId('inv-joined'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.systemPrompt.assemble({ scope: handle.agent })).resolves.toBeDefined() + // A scopeless assembly belongs to no agent, so it cannot be an unjoined one. + await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined() + }) }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index afe297a406..8266a4850b 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -499,6 +499,22 @@ describe('replacing a composition', () => { expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) }) + it('names an agent that was published without joining any preset', async () => { + const ctx = await harness() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + + await ctx.agents.create({ sessionId: SessionId('sess-unjoined-warn') }) + // Advisory, not fatal: a synchronous `agent/created` throw would veto + // publication, and creating an agent outside the roster stays legal. + expect(warnings.filter(line => line.includes('sess-unjoined-warn'))).toHaveLength(1) + expect(warnings.at(-1)).toMatch(/without joining an agent preset/) + + warnings.length = 0 + await agentOn(ctx, 'sess-joined-quiet', 'minimal') + expect(warnings).toEqual([]) + }) + it('composes an agent that had nothing installed', async () => { // An agent created without a preset has no binding to re-link, so the // switch is its first bind — exactly a mount — and once bound only the diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index 47d5577207..a12c5a0149 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -18,12 +18,18 @@ { "path": "../../../vendor/include" }, + { + "path": "../../core/agent" + }, { "path": "../../core/scope" }, { "path": "../../core/session" }, + { + "path": "../../core/system-prompt" + }, { "path": "../../settings/settings" }, diff --git a/packages/scaffold/server/src/server.ts b/packages/scaffold/server/src/server.ts index e6c22d7ff2..cb75881303 100644 --- a/packages/scaffold/server/src/server.ts +++ b/packages/scaffold/server/src/server.ts @@ -216,6 +216,12 @@ export class HarnessSdkServer { } private async createSession(sessionId: string): Promise { + // No preset composition: the SDK server's compositions keep the + // model-facing rows in the host plane, so this agent reads them from the + // global registry layer. A deployment that configures `dsh-agent-presets` + // has to join one here first — registration visibility inherits only along + // the `dsh-scope` parent chain, and an agent created without that link + // reads an empty global layer and reaches the model with no tools. const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, diff --git a/packages/self-modification/tool-cordis/README.i18n.yaml b/packages/self-modification/tool-cordis/README.i18n.yaml index ef1f441711..0062df8349 100644 --- a/packages/self-modification/tool-cordis/README.i18n.yaml +++ b/packages/self-modification/tool-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/tool-cordis/README.md -README.md: f2a65043a1d2f74553e98caf59ed3d38b5a70b7c -README.zh.md: 66742094992d219ccfbd60b935dcd10e48cb12b8 +README.md: 7c8e2e1301f81c5579e7c2cadaf34d0a97270ba7 +README.zh.md: 2f7e66cd9c79c71373618b397e6841b003033bdd diff --git a/packages/self-modification/tool-cordis/README.md b/packages/self-modification/tool-cordis/README.md index f2a65043a1..7c8e2e1301 100644 --- a/packages/self-modification/tool-cordis/README.md +++ b/packages/self-modification/tool-cordis/README.md @@ -87,3 +87,4 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). - **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths. - **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. +- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. That matched the host-plane row this package started as (one per process); it becomes observable once several sessions run the same preset concurrently. Per-session temporary plugins would need the group and table keyed by the calling agent. diff --git a/packages/self-modification/tool-cordis/README.zh.md b/packages/self-modification/tool-cordis/README.zh.md index 6674209499..2f7e66cd9c 100644 --- a/packages/self-modification/tool-cordis/README.zh.md +++ b/packages/self-modification/tool-cordis/README.zh.md @@ -87,3 +87,4 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 - **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。 - **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 +- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。这与本包最初作为宿主平面行的语义一致(每进程一份);只有当多个会话并发运行同一 preset 时才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。 diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 01631bb22c..7419982a9f 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1212,7 +1212,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'presentAs(mode: ToolPresentationMode): () => void', - jsDoc: '/**\n * Present this agent\'s tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent\'s model sees.\n * @returns the exact disposer that restores the deployment default.\n */', + jsDoc: '/**\n * Present the calling scope\'s tools in `mode` instead of the deployment\n * default. Nearest scope on the chain wins, so a preset\'s standing\n * declaration covers every agent joined under it.\n *\n * Scoped only, and one declaration per scope: this is how an agent preset\n * composes Code Mode agents beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation the covered agents\' models see.\n * @returns the exact disposer that restores the deployment default.\n */', }, { signature: 'register(definition: ToolDefinition): () => void', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c7b7530b09..f2103cd320 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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-projection/README.md -README.md: 2615b253999c798172168ec9d0232eb965b07fbc -README.zh.md: 0712e9c7a61fbcf43939791b3e7cd24af6777c3a +README.md: 9018b133bb69ed4717fede14c9a2070a07c3fa62 +README.zh.md: b91908117fd452855a82976515d13165803def43 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 2615b25399..9018b133bb 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -42,6 +42,7 @@ None; projections never assemble or send provider requests. ## Known Limitations and Deferred Work - **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. +- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 0712e9c7a6..b91908117f 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -42,6 +42,7 @@ ## 已知限制与暂缓事项 - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 +- **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。 - **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f36be6b1b6..e160b3dd43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ version: link:../../packages/bash/pwsh-sandbox + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../packages/session/session-projection '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference From 72344fce93b920b6d3241793b68735d944cdef98 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 22:40:57 +0800 Subject: [PATCH 34/67] fix(web): edit a declared provider's name and protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Models editor card curated its pi-ai fields by what every route has, so `displayName` and `api` — the two a hand-declared route names for itself — were asked for at creation and then reachable only through settings.yaml. The editor now renders both for a route the directory reports as declared, from the same namespace schema the create card reads. A catalog route gets neither: it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override all of them. Clearing the name unsets it and the route falls back to its id, which is what the field's placeholder shows; storing the empty string would be refused by the adapter. A declared profile naming no protocol selects nothing rather than reading as if it had picked the first choice. The Provider ID stays fixed: it is the settings dict key, it is referenced from `agent-default-model` and every logged request header, and it is the stem of a credential reference the page can never read back to move. Fixes #2204 --- ...-a-provider-from-the-models-page.i18n.yaml | 4 +- ...claring-a-provider-from-the-models-page.md | 12 ++- ...ring-a-provider-from-the-models-page.zh.md | 12 ++- apps/web/tests/models-settings.e2e.ts | 36 ++++++- .../models-settings/declared-edit.expected.md | 65 +++++++++++++ docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 + docs/user/guide/providers.zh.md | 2 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../ui-models/src/client/ModelsSection.tsx | 7 ++ .../ui-models/src/client/ProviderEditor.tsx | 72 +++++++++++++- .../ui-models/tests/provider-form.spec.tsx | 93 +++++++++++++++++++ 14 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 apps/web/tests/snapshots/models-settings/declared-edit.expected.md diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml index c34594bd36..bff5cc88fc 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.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-04-declaring-a-provider-from-the-models-page.md -2026-08-04-declaring-a-provider-from-the-models-page.md: 44441da5427dbcd3f0651c2c137a5132ff6d968b -2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 974c4fa5be0893e8fdf6a9142877a8b966708dab +2026-08-04-declaring-a-provider-from-the-models-page.md: 3c63254364e3803e9bddab2a5aad4c092e4f0426 +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: c38b0387b3c7778b93fb43232c22a7acb24dc281 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md index 44441da542..3c63254364 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -22,22 +22,30 @@ Fetching asks about the endpoint **the form currently shows** — a base URL edi The protocol choices come from the namespace's **own schema**, read through the settings descriptor the page already fetches (`providers.*.api` is a union of the adapter's `supportedProtocols()`). No new wire field, no constant in the client, and no way for the offered choices to drift from the accepted ones. +The editor reaches the two fields a route the directory reports as **declared** names for itself — its display name and that protocol. A create card asking for a field no editor can change leaves that field reachable only through `settings.yaml`, which is the posture this note set out to end. Both render in the fold beside the endpoint, the protocol from the same schema read; clearing the name unsets it and the route falls back to its id, which is what the field's placeholder shows, while the protocol has no fallback to clear to. A catalog route gets neither: it defaults its name from its catalog entry, and each of its models carries its own protocol, so a route-level one could only override every one of them. + +The **Provider ID** is the one create-card field that stays fixed, and not for want of a control. It is the `providers.` dict key, so changing it is a move rather than an edit, and the editor is addressed by the `settingsPath` that move would invalidate. It is referenced from outside this namespace — `agent-default-model` stores a `provider` string, and every `request/header` in every session log already records one — so a rename would silently strip meaning from referents this page cannot see. And it is the stem of the derived credential reference: the page writes keys but can never read one back, so it cannot move `OLD_API_KEY` to `NEW_API_KEY`, leaving a rename to either orphan the stored key or point the profile at a reference under the previous name. Declaring the new route and deleting the old one does all three explicitly, and the page already offers both halves. + ## Alternatives considered **Declare a provider through `ProviderEditor` with extra fields.** One card instead of two, but the editor is addressed by `settingsPath`, and a route being named has no path yet. Recomputing the path per keystroke would remount the card and discard the draft; deferring it would mean the editor's whole write path no longer described what it was editing. **Add a wire field for the protocol list.** Explicit. But the settings schema already crosses the wire and already contains the union, so a second copy could disagree with the first — and the one the adapter enforces is the schema. +**Let the Provider ID be edited, with the page performing the move.** The card would unset the old key and set the new profile in one `settings.mutate`, and the rest is a rename. But the credential cannot travel with it — the page holds a redacted descriptor, never a value — and the referents in other namespaces and in logged sessions have no rename path at all, so the honest version of this feature is the create-then-delete the page already has. + +**Offer the protocol on every pi-ai route, with an inherit choice.** Symmetric with the base URL beside it, and repointing a catalog route at a gateway speaking another wire protocol is a real thing to want. But no consumer asks for it, one wrong pick silently repoints every model on the route, and the inherit choice would be the only way to write a declared route into a profile the adapter refuses. `settings.yaml` still expresses the repoint for a deployment that means it. + **Fetch against the stored profile instead of the live form.** No key would leave the form for an unsaved provider. But the flow that needs fetching most is the one where nothing is stored yet, and a form whose endpoint was edited would quietly interrogate the old one. **Write adopted candidates straight into the list.** Fewer clicks, but a fetch would then overwrite capacities the user had corrected, and a listing that discloses only ids would replace real numbers with nothing. ## Consequences -A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list. +A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list, plus a name and a protocol on a declared route. What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is the one namespace whose profiles describe a whole provider — a `llm-deepseek` route stays a composition fact. Interrogation reaches only OpenAI-compatible endpoints, so a gateway speaking another protocol reports that it cannot be asked and its models are typed in. And the page now holds a key in component state for the duration of a fetch, which is the same exposure `credentials.set` already has and no longer than the card lives. ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. +`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename. diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md index 974c4fa5be..c38b0387b3 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md @@ -22,22 +22,30 @@ Status: implemented 协议选项来自该 namespace **自己的 schema**,经页面本就会获取的 settings 描述符读出(`providers.*.api` 是适配器 `supportedProtocols()` 的一个 union)。没有新增协议字段,客户端里没有常量,提供的选项也无从与被接受的集合发生漂移。 +对于目录报告为**已声明**的路由,编辑器够得着它为自己命名的那两个字段——显示名称与该协议。创建卡片索要一个编辑器改不了的字段,等于把该字段留在只有 `settings.yaml` 才能触及的位置,而那正是本记录要终结的姿态。两者都渲染在折叠区里、紧挨着端点,协议读的是同一份 schema;清空名称即取消设置,路由退回自己的 id——字段占位符显示的就是它,而协议没有可退回的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。 + +**Provider ID** 是创建卡片上唯一保持固定的字段,原因不是没做控件。它是 `providers.` 这个字典键,因此改它是一次搬移而非一次编辑,而编辑器正是由那次搬移会作废的 `settingsPath` 寻址的。它还被本 namespace 之外引用——`agent-default-model` 存着一个 `provider` 字符串,每条会话日志里的每个 `request/header` 也都已经记下了一个——因此重命名会悄悄抽空这个页面看不见的那些引用。它同时是派生凭据引用的词干:页面写得了密钥却永远读不回来,因此无法把 `OLD_API_KEY` 搬到 `NEW_API_KEY`,重命名要么让已存密钥成为孤儿,要么让 profile 指向一个仍带旧名的引用。声明新路由再删掉旧的,把这三件事都显式做了一遍,而页面本就提供这两半。 + ## Alternatives considered **在 `ProviderEditor` 上加字段来声明提供方。** 两张卡片变一张,但编辑器由 `settingsPath` 寻址,而正在被命名的路由还没有路径。逐次按键重算路径会让卡片重新挂载并丢掉草稿;推迟计算则意味着编辑器的整条写入路径不再描述它正在编辑的东西。 **为协议列表新增一个协议字段。** 显式。但 settings schema 本来就会跨越协议层、本来就含有那个 union,因此第二份副本可能与第一份不一致——而适配器强制执行的是 schema 那一份。 +**开放 Provider ID 编辑,由页面来完成这次搬移。** 卡片可以在一次 `settings.mutate` 里取消旧键、设置新 profile,剩下的就只是改名。但凭据没法跟着走——页面手里只有脱敏描述符,从来没有值——而其他 namespace 与已记录会话里的引用根本没有重命名通路,因此这个功能诚实的版本,就是页面已经具备的「先建后删」。 + +**给每条 pi-ai 路由都提供协议,并附一个「继承」选项。** 与紧挨着的 API 地址对称,而且把内置目录路由指向讲另一种协议的网关确实是有人会想要的事。但目前没有消费方提出这个诉求,一次选错就会静默地把该路由上每个模型都重指,而「继承」选项还会成为把已声明路由写成适配器拒绝的 profile 的唯一途径。真要这么做的部署,`settings.yaml` 仍然表达得了。 + **针对已存 profile 而非实时表单发起获取。** 对尚未保存的提供方来说,密钥就不会离开表单。但最需要获取的恰恰是「什么都还没存」的那条流程,而端点已修改的表单会悄悄去询问旧地址。 **把采纳的候选直接写进列表。** 点击更少,但一次获取就会覆盖用户已更正的容量,而只公布 id 的列表会把真实数字替换成空。 ## Consequences -网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表。 +网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表,已声明路由上还多了一个名称输入框和一个协议选择框。 代价是:只有 pi-ai 路由可以手工声明,因为 `llm-pi-ai` 是唯一一个其 profile 描述整个提供方的 namespace——`llm-deepseek` 路由仍是组合面的事实。询问只覆盖 OpenAI 兼容端点,因此讲其他协议的网关会报告自己无法被询问,其模型需手工键入。另外,页面在一次获取期间会把密钥保存在组件状态里,这与 `credentials.set` 已有的暴露面相同,且不长于卡片的存活时间。 ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。 +`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 364b00f9e5..6afdfa4429 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -28,6 +28,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md') +const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md') const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md') const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() @@ -209,6 +210,37 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('reopens the name and protocol a declared route was created with', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declared-identity')) + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '编辑 Acme Gateway (acme-gateway)' }).click() + await dialog.getByText('自定义设置').click() + // The create card asked this route for a name and a protocol because + // nothing can default them; the editor reaches the same two fields rather + // than sending the user to settings.yaml for what only this route names. + const protocol = dialog.getByLabel('API 协议') + await protocol.waitFor({ timeout: 10_000 }) + expect(await protocol.inputValue()).toBe('openai-completions') + const name = dialog.getByLabel('显示名称', { exact: true }) + expect(await name.inputValue()).toBe('Acme Gateway') + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE) + + await protocol.selectOption('anthropic-messages') + await name.fill('Acme 网关') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0) + // The adapter re-resolved the route under the new protocol and re-registered + // it under the new name: an unserviceable profile would have been refused + // at the write instead, and a rename that did not re-register would leave + // the old label on the row. + await dialog.getByText('Acme 网关', { exact: true }).first().waitFor({ timeout: 10_000 }) + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('api: anthropic-messages') + expect(document).toContain('displayName: Acme 网关') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('confirms an identified provider deletion before removing its profile and key', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) @@ -243,8 +275,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'configured.expected.md', 'declared.expected.md', 'delete.expected.md', - 'empty.expected.md', 'native-delete.expected.md', + 'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md', + 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md new file mode 100644 index 0000000000..e36c7ff2e8 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -0,0 +1,65 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: minimax-cn + - img "API 密钥已配置" + - button "编辑 minimax-cn": 编辑 + - button "删除 minimax-cn": 删除 + - listitem: + - text: Acme Gateway 自定义 + - button "编辑 Acme Gateway (acme-gateway)": 编辑 + - button "删除 Acme Gateway (acme-gateway)": 删除 + - text: Acme Gateway acme-gateway API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: + - text: 自定义设置 显示名称 + - textbox "显示名称": + - /placeholder: acme-gateway + - text: Acme Gateway + - text: API 地址 + - textbox "API 地址": + - /placeholder: https://gateway.acme.example/v1 + - text: https://gateway.acme.example/v1 + - text: API 协议 + - combobox "API 协议": + - option "openai-completions" [selected] + - option "openai-responses" + - option "anthropic-messages" + - region "模型目录": + - text: 模型目录 已自定义模型目录 + - button "恢复默认模型" + - button "获取可用模型" + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: acme-large + - textbox "显示名称 1": + - /placeholder: 显示名称 + - button "容量 1" + - button "删除模型 1" + - button "添加模型" + - button "取消" + - button "保存" + - button "添加提供方": + - img + - text: 添加提供方 + - button "添加自定义提供方": + - img + - text: 添加自定义提供方 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index c95a0db6c0..e482a07615 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/user/guide/providers.md -providers.md: 4667e54161e77a62d454f3f78a8164d5c79b78c8 -providers.zh.md: 15e0ccc826a5c285c71d4776793f8048c11f6254 +providers.md: fff2e9f66b1dcec2b549420ee43f908043983338 +providers.zh.md: a27adefdbb76c43d099982b0503a9adde139597f diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 4667e54161..fff2e9f66b 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -29,6 +29,8 @@ That holds for providers that authenticate with an API key. The catalog also car ![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) +Every field but the Provider ID stays editable afterwards: **Edit** on the row reopens the same fields, with the display name and the protocol under 自定义设置 beside the base URL. Clearing the display name falls back to the Provider ID. The Provider ID itself is fixed: it names the route in requests, in `agent-default-model`, and in every session already logged, and it is the stem of the credential reference the page can never read back — so renaming a route means declaring a new provider and deleting the old one. + **Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 15e0ccc826..a27adefdbb 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -29,6 +29,8 @@ Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配 ![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) +除 Provider ID 外的每个字段之后都还能改:行上的**编辑**会重新打开这些字段,显示名称和协议在「自定义设置」里、紧挨着 API 地址;显示名称清空即退回 Provider ID。Provider ID 本身固定不可改:它在请求里、在 `agent-default-model` 里、在每一条已记录的会话里点名这条路由,同时还是凭据引用的词干,而页面永远读不回凭据值——因此重命名一条路由等于声明一个新提供方再把旧的删掉。 + **让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`,profile 里只记录引用它的变量名。 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index c03e091a5e..bb7212c7ff 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: e0c5728d47e053df1934ef9eb69df3f8d985a4ec -README.zh.md: fe11e6cdd190e19d5b5dac6dc95950ba59a3172b +README.md: f86564151a3c3788c3002421cac465ef98177281 +README.zh.md: f94438db18a2034b4180f5b79ea521ad7037754c diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index e0c5728d47..f86564151a 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. @@ -28,7 +28,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. +- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). Both families expose `baseURL` and model `id`/`name`/`contextWindow`/`maxTokens`; a hand-declared pi-ai route also exposes `displayName` and `api`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. - **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index fe11e6cdd1..f94438db18 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 @@ -28,7 +28,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, ## 已知限制与暂缓事项 -- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 +- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。两个家族都公开 `baseURL` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;手工声明的 pi-ai 路由还公开 `displayName` 与 `api`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 - **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 5551b847fd..be4532444c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -54,6 +54,8 @@ interface EditorTarget extends ProviderIdentity { settingsPath: readonly string[] /** Writable credential identified under this page's conventional reference. */ credentialRef?: string + /** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */ + declared?: boolean } /** Values that vary around the shared provider-editor rendering. */ @@ -71,6 +73,7 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): provider={target.provider} displayName={target.displayName} settingsPath={target.settingsPath} + {...target.declared === true ? { declared: true } : {}} {...props} /> ) @@ -135,6 +138,10 @@ function targetOf(row: ProviderRow): EditorTarget { settingsNs: row.entry.settingsNs, settingsPath: row.entry.settingsPath, ...credentialRef === undefined ? {} : { credentialRef }, + // Absent is not "shipped": an adapter that answers nothing leaves the + // route-level fields only a declared route owns off the card, exactly as + // it leaves the custom tag off the row. + ...row.entry.declared === true ? { declared: true } : {}, } } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 01978273ae..afae84a89f 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -7,7 +7,9 @@ * a key is entered; a blank key materializes a reference-free profile for * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families and DeepSeek's id/name/context-window model catalog). + * both families, DeepSeek's id/name/context-window model catalog, and the + * wire protocol of a pi-ai route the adapter does not ship — the field the + * create card asked that route for, editable here for the same reason). * Reasoning effort is deliberately absent: it is a per-MODEL capability, and * the models under one provider disagree about it, so a provider-scoped * control can only be set to a value some of them reject. The composer's @@ -30,7 +32,7 @@ import { import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' -import { deriveKeyRef, messageOf } from './store.ts' +import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -48,6 +50,14 @@ export interface ProviderEditorProps { displayName: string /** Hide the title row (the add card renders its own provider select). */ hideTitle?: boolean + /** + * Whether the adapter reports this route as hand-declared — absent from its + * installed catalog. Such a route carries its own wire protocol, chosen when + * it was created and editable here for the same reason; a catalog route's + * models each carry theirs, so a route-level protocol there could only + * override every one of them and the card does not offer it. + */ + declared?: boolean /** The owning namespace view (schema, layers, secrets). */ namespace: SettingsNamespaceView /** Path from the section root to this provider's profile. */ @@ -139,6 +149,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const disabled = props.readOnly || busy const layout = layoutOf(namespace.ns) const keyRef = refFor(namespace, settingsPath, props.provider) + // The same schema read the create card makes, so the choices offered here + // and there cannot drift apart: both come from the adapter's own `Config`. + const protocols = useMemo(() => protocolChoices(namespace), [namespace]) useEffect(() => { let stale = false @@ -289,11 +302,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { } /** - * The curated fields of one known adapter family. Taking the narrowed - * family as a parameter is what makes `EFFORT_FIELD` total here: an - * unknown namespace never reaches this body. + * The curated fields of one known adapter family. The family arrives + * narrowed so the per-family branches below are total: an unknown namespace + * renders the hint instead and never reaches this body. */ const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { + // What a hand-declared route names for itself and nothing else can supply. + // A whole-section `llm-deepseek` profile is a composition fact with no + // per-route identity for its schema to carry, hence the family test. + const ownsIdentity = family === 'pi-ai' && props.declared === true const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) @@ -334,6 +351,28 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
{t('customized')}
+ {/* The name and the protocol are the create card's two remaining + profile fields; a route the adapter ships defaults both from + its catalog entry and neither belongs on its card. */} + {ownsIdentity + ? ( +
+ {t('customDisplayName')} + { setField('displayName', event.target.value) }} + /> +
+ ) + : null}
{t('baseUrl')}
+ {/* The protocol sits beside the endpoint it describes, as it does + on the create card. */} + {ownsIdentity + ? ( +
+ {t('customApi')} + +
+ ) + : null} {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 9ff96cdebc..696e2258be 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -719,10 +719,103 @@ describe('hand-declared providers', () => { expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput]) cleanup() + // A shipped route's models each carry their own protocol, so its editor + // offers no route-level protocol to override them with. await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) openEditor('openai') fireEvent.click(screen.getByText(en.customized)) expect(fields()).toEqual([en.keyInput, en.baseUrl]) + cleanup() + + // A hand-declared route named its own protocol at creation, so editing it + // reaches the same field the create card asked for. + await mountSection({ + providers: { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + expect(fields()).toEqual([en.keyInput, en.customDisplayName, en.baseUrl, en.customApi]) + }) + + it('renames a declared route and falls back to its id when the name is cleared', async () => { + const { mutate } = await mountSection({ + providers: { + 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://acme.test/v1' }, + }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + const name = screen.getByLabelText(en.customDisplayName) + expect(name.value).toBe('Acme Gateway') + // The route id, not the stored name: it is what the route will be called + // the moment the field is cleared. + expect(name.placeholder).toBe('acme-gateway') + fireEvent.change(name, { target: { value: 'Acme 网关' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(firstMutate(mutate).ops) + .toEqual([{ op: 'set', path: ['providers', 'acme-gateway', 'displayName'], value: 'Acme 网关' }]) + }) + + it('drops the stored name rather than storing an empty one the adapter refuses', async () => { + // `llm-pi-ai` rejects an empty displayName outright, so clearing the field + // must unset it — which is also what the user means: use the route id. + const { mutate } = await mountSection({ + providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: ' ' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(firstMutate(mutate).ops) + .toEqual([{ op: 'unset', path: ['providers', 'acme-gateway', 'displayName'] }]) + }) + + it('edits the protocol a declared route was created with', async () => { + const { mutate } = await mountSection({ + providers: { + 'acme-gateway': { + apiKeyEnv: 'ACME_GATEWAY_API_KEY', + api: 'openai-completions', + baseURL: 'https://gateway.acme.example/v1', + models: [{ id: 'acme-large' }], + }, + }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + const protocol = screen.getByLabelText(en.customApi) + expect(protocol.value).toBe('openai-completions') + fireEvent.change(protocol, { target: { value: 'anthropic-messages' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + // Only the protocol travels: every other stored field is unchanged, so no + // op restates it. + expect(firstMutate(mutate)).toEqual({ + ns: 'llm-pi-ai', + ops: [{ op: 'set', path: ['providers', 'acme-gateway', 'api'], value: 'anthropic-messages' }], + expectedRevision: 3, + }) + }) + + it('selects nothing for a declared route whose profile names no protocol', async () => { + // A route hand-written into settings.yaml with no model needs no protocol + // to resolve, so the card can be opened over one. The select must not read + // as if that route had picked its first choice. + await mountSection({ + providers: { 'acme-gateway': { baseURL: 'https://gateway.acme.example/v1' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + expect(screen.getByLabelText(en.customApi).value).toBe('') }) it('retries only the key after the profile landed, and reports the provider on cancel', async () => { From 0a2c0a92acca59b772b06133b1594fbc51e83aed Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 22:59:14 +0800 Subject: [PATCH 35/67] ci: address review feedback for Windows CI unification - Rewrite ci-workflow.spec.ts to assert new single-job native Windows topology - Remove check:windows-wine from root AGENTS.md command list - Archive 2026-08-08-native-windows-pull-request-ci (dual-lane topology superseded) with Archived: metadata and inbound-link updates - Update cross-references in serial-cross-platform-ci-reference, portable-required-pull-request-ci, and pnpm-action-setup notes - Add Windows pool coverage to the failover runbook (EN + zh) - Remove dead wine-windows-gates.sh - Remove unnecessary Playwright Chromium install from serial-windows - Fix .github/AGENTS.md windows-* label wording - Fix PR description before-state accuracy --- ...8-native-windows-pull-request-ci.i18n.yaml | 6 + ...26-08-08-native-windows-pull-request-ci.md | 1 + ...08-08-native-windows-pull-request-ci.zh.md | 1 + ...rial-cross-platform-ci-reference.i18n.yaml | 4 +- ...7-21-serial-cross-platform-ci-reference.md | 4 +- ...1-serial-cross-platform-ci-reference.zh.md | 4 +- ...ortable-required-pull-request-ci.i18n.yaml | 4 +- ...07-23-portable-required-pull-request-ci.md | 6 +- ...23-portable-required-pull-request-ci.zh.md | 6 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 8 +- .../2026-07-26-ci-failover-runbook.zh.md | 8 +- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 4 +- ...ction-setup-for-symmetric-ci-caching.zh.md | 4 +- ...8-native-windows-pull-request-ci.i18n.yaml | 6 - .github/AGENTS.md | 2 +- .github/workflows/ci.yml | 7 - AGENTS.md | 1 - scripts/ci-workflow.spec.ts | 56 ++-- scripts/wine-windows-gates.sh | 282 ------------------ 21 files changed, 78 insertions(+), 344 deletions(-) create mode 100644 .agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml rename .agents/notes/{implemented => archived}/process/2026-08-08-native-windows-pull-request-ci.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-08-native-windows-pull-request-ci.zh.md (99%) delete mode 100644 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml delete mode 100755 scripts/wine-windows-gates.sh diff --git a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml new file mode 100644 index 0000000000..0a565b17d1 --- /dev/null +++ b/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.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/archived/process/2026-08-08-native-windows-pull-request-ci.md +2026-08-08-native-windows-pull-request-ci.md: 3f69f335f6d873124fbbc1fd96d06ff2b3c8dc43 +2026-08-08-native-windows-pull-request-ci.zh.md: 136a7e8791da141cc12ba77d148f47b28db4790b diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md rename to .agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.md index 6a62fddb79..3f69f335f6 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.md @@ -1,6 +1,7 @@ # Agent Note: Dual Wine and native Windows pull-request CI Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md rename to .agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.zh.md index 990b8ed143..136a7e8791 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -1,6 +1,7 @@ # Agent Note: Wine 与原生 Windows 双通道拉取请求 CI Status: implemented +Archived: 2026-08-10 [English](2026-08-08-native-windows-pull-request-ci.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 33bffe92ff..a65b22ba3f 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 07dc430e6fed3fe75a006ca03523bd6e4fc969d0 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 8bbb60cdead2957069de22ecaddf01c6cd9fb305 +2026-07-21-serial-cross-platform-ci-reference.md: b7b90f84095fe317f92c051f66da0bf8ee080b19 +2026-07-21-serial-cross-platform-ci-reference.zh.md: d62d7d754eb0343d8b1dd86adae7f501c04b177b diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 07dc430e6f..b7b90f8409 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active reference is `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and native Windows jobs plus the Node compatibility and Python contracts. On a push to `master`, the active references are `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` on the in-house self-hosted pools — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses standard `windows-2025` under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs native on the enterprise larger runner under normal operation, failing over to the self-hosted pool under `DSH_CI_FAILOVER=selfhosted`; the archived [dual Windows decision](../archived/process/2026-08-08-native-windows-pull-request-ci.md) records the prior Wine/native topology. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 8bbb60cdea..d62d7d754e 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——该热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求运行合并后的 Linux 与原生 Windows 作业,以及 Node 兼容性与 Python 约定。向 `master` 推送时,当前启用的参考作业是公司自有自托管池上的 `serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)使用标准 `windows-2025`;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在正常运行下使用企业级大型运行器,在 `DSH_CI_FAILOVER=selfhosted` 时切换至自托管池;已归档的[双 Windows 决策](../archived/process/2026-08-08-native-windows-pull-request-ci.md)记录此前的 Wine/原生拓扑。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index e67565f381..4738f87fda 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: b7c637408afa32611c4e70073ddec9aaabfba7f5 -2026-07-23-portable-required-pull-request-ci.zh.md: ee88412d982da5de613d8dfdbdcdd9977c899d2d +2026-07-23-portable-required-pull-request-ci.md: 6f7a0e0e6354e9bea632d1b00881a44acd921f9e +2026-07-23-portable-required-pull-request-ci.zh.md: 0ec92f11efa55ed155ada1ba858cd1878398f1ec diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index b7c637408a..6f7a0e0e63 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs native on the enterprise larger runner under normal operation, failing over to the self-hosted pool under `DSH_CI_FAILOVER=selfhosted` ([unified Windows CI note](2026-08-10-unified-windows-selfhosted-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial cross-platform standbys continuously validate the failover targets. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. -The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / native complete` remain dependencies of `all checks passed`. The master-only `serial / windows (self-hosted standby)` is deliberately absent from the aggregate, mirroring the Linux standby pattern. The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. @@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) ## Consequences -Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine job keeps the required Windows verdict on standard Linux allocation. The independent native job uses standard Windows allocation without delaying or changing the aggregate. A live exact-head run distinguishes the commands branch protection consumes from the separate diagnostic contract; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. +Ordinary pull requests spend enterprise capacity on the Linux critical path while the native Windows job uses the enterprise larger runner on the normal path and the self-hosted pool under failover. A live exact-head run distinguishes the commands branch protection consumes from the separate diagnostic contract; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. Standard compatibility, required Wine, and diagnostic native Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index ee88412d98..0ec92f11ef 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在正常运行下使用企业级大型运行器,在 `DSH_CI_FAILOVER=selfhosted` 时切换至自托管池([统一 Windows CI 笔记](2026-08-10-unified-windows-selfhosted-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,跨平台串行备援持续验证故障切换目标。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / native complete` 继续作为 `all checks passed` 的依赖项;仅 master 触发的 `serial / windows (self-hosted standby)` 被刻意排除,与 Linux 备援模式对称。 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 作业让必需的 Windows 判定继续使用标准 Linux 运行器容量。独立原生作业使用标准 Windows 运行器容量,不会延迟或改变聚合流程。一次实际的分支头精确运行会区分分支保护采用的命令与单独的诊断约定;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而原生 Windows 作业在正常运行下使用企业级大型运行器、故障切换时使用自托管池。独立原生作业使用标准 Windows 运行器容量,不会延迟或改变聚合流程。一次实际的分支头精确运行会区分分支保护采用的命令与单独的诊断约定;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 企业级运行器分配能力下降时,标准兼容性作业、必需的 Wine 作业与诊断性原生 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index f4fe0c74d2..2403d869c6 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 72261f95ea74b61e3915a1a6419b2c2e616efbd9 -2026-07-26-ci-failover-runbook.zh.md: 1ee679eabc296ab31d71b87945409788539425bd +2026-07-26-ci-failover-runbook.md: 25bfb8cc7ca6d53c5d28e7c941facdb86e5e147b +2026-07-26-ci-failover-runbook.zh.md: 22195500f5a28c9e92bc8aac8c58028628ab1d6b diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 72261f95ea..25bfb8cc7c 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -6,16 +6,20 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md) ## Problem -The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`, `windows`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`, and only the Windows leg has no in-house substitute at all — during the 2026-07-27 outage the standard pools recovered first, which is the ordering this design bets on. An outage therefore needs a switch any responder with repository write access can throw without merging anything. +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. The required Windows job (`windows node 24 / native complete`) runs on the hosted enterprise larger runner. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage AND a hosted Windows-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`. An outage therefore needs a switch any responder with repository write access can throw without merging anything. ## Decision -Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. +Each of the three required Linux worker jobs, the required Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all five retarget onto the in-house self-hosted pools: the Linux jobs and verdict onto the `vm-backup` pool (`[self-hosted, linux, x64, vm-backup]`), coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped; the Windows job onto the `dsh-win-ci` pool (`[self-hosted, dsh-win-ci, windows]`). The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push. ### What the in-house pool is `vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite. +#### Windows pool + +`dsh-win-ci`: 16 always-on runner instances on the in-house Windows CI server. Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git, and enable Developer Mode for symlink support; Playwright Chromium is installed per-run by the `serial / windows (self-hosted standby)` lane. Check the latest `serial / windows (self-hosted standby)` run before switching: a green standby verifies the pool can execute `check:ci:windows-complete` end-to-end. + ### Switch (any repository writer, ~1 minute, no merge) 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 1ee679eabc..22195500f5 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -6,16 +6,20 @@ Status: implemented ## 问题 -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`、`windows`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`,且只有 Windows 这条腿完全没有自有替代——2026-07-27 的故障中标准池率先恢复,本设计押注的正是这一顺序。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。必需的 Windows 作业(`windows node 24 / native complete`)运行在托管的企业级大型运行器上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障和托管 Windows 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 ## 决策 -三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 +三个必需的 Linux 工作作业、必需的 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,五者全部切换到公司自有的自托管池:Linux 作业与判定位移入 `vm-backup` 池(`[self-hosted, linux, x64, vm-backup]`),覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;Windows 作业移入 `dsh-win-ci` 池(`[self-hosted, dsh-win-ci, windows]`)。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 和 `serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 ### 自有池是什么 `vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。 +#### Windows 池 + +`dsh-win-ci`:公司自有 Windows CI 服务器上的 16 个常驻运行器实例。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git,并启用开发人员模式以支持符号链接;Playwright Chromium 由 `serial / windows (self-hosted standby)` 通道在每次运行时安装。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:绿色热备验证该池可完整执行 `check:ci:windows-complete`。 + ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index c0a7dcfc02..b619bdc1d8 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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-07-26-pnpm-action-setup-for-symmetric-ci-caching.md -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 499141ca6a3703a12d10c195b732441d49419599 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: c57b7d32cd7204a845ab1884e9ae0b9045749a1c +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 612c42d19e3891f3fa036fea74b36e36da2fb5cd +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 7c82c787f11e93cbb62209d25df741d0abeba3dd diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 499141ca6a..612c42d19e 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -13,8 +13,8 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. -- **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Cache-less or persistent** (no store-cache action): the required native Windows job, serial-windows standby, serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index c57b7d32cd..7c82c787f1 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -13,8 +13,8 @@ Status: implemented `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 -- **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **无缓存或持久化**(不使用 store 缓存 action):必需的 Windows 作业、serial-windows 热备、serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml deleted file mode 100644 index 07eb13b5cd..0000000000 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 6a62fddb79670c3ab4cc0446796dbffd7130aed9 -2026-08-08-native-windows-pull-request-ci.zh.md: 990b8ed1434934337b8ff20c5f3be2c03cd9c61b diff --git a/.github/AGENTS.md b/.github/AGENTS.md index b2721fb84e..ad2aeac354 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The required pull-request `windows` job and the master `serial-windows` standby both run on real Windows; the self-hosted pool uses labels `[self-hosted, dsh-win-ci, windows]`. Under failover (`DSH_CI_FAILOVER=selfhosted`) the `windows` job retargets onto the self-hosted pool. +Run Windows CI jobs under native `pwsh`. The required pull-request `windows` job and the master `serial-windows` standby both run on real Windows: normally on the hosted enterprise runner, failing over to the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under `DSH_CI_FAILOVER=selfhosted`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a0482e103..f67a86c5fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -561,13 +561,6 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - # The persistent VM image owns Playwright's Windows system dependencies; - # this step also proves that browser provisioning remains usable for - # failover. - - name: Install Playwright Chromium - shell: pwsh - run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium - - name: Run complete unsharded Windows gate inventory serially shell: pwsh env: diff --git a/AGENTS.md b/AGENTS.md index a8a202147c..7b326fbdb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,6 @@ pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime -pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2269c7cbee..b6d7d858ff 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -27,46 +27,60 @@ describe('CI workflow', () => { } }) - it('keeps Wine blocking while native Windows reports independently', () => { + it('keeps a single required native Windows job with failover and a master-only standby', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.windows) - || !isRecord(workflow.jobs['windows-native']) + || !isRecord(workflow.jobs['serial-windows']) || !isRecord(workflow.jobs['all-checks-passed'])) { - throw new TypeError('CI workflow must define Wine, native Windows, and aggregate jobs') + throw new TypeError('CI workflow must define windows, serial-windows, and all-checks-passed jobs') } const windows = workflow.jobs.windows - const windowsNative = workflow.jobs['windows-native'] + const serialWindows = workflow.jobs['serial-windows'] const aggregate = workflow.jobs['all-checks-passed'] - if (!Array.isArray(windows.steps) || !Array.isArray(windowsNative.steps) || !Array.isArray(aggregate.needs)) { - throw new TypeError('Windows jobs must define steps and the aggregate must define needs') + if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows job must define steps and the aggregate must define needs') } - const nativeCommandSteps = windowsNative.steps.filter((step): step is Record & { run: string } => ( + const commandSteps = windows.steps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) - expect(windows['runs-on']).toBe('ubuntu-latest') - expect(windows.name).toBe('windows node 24 / wine blocking') + // Required PR job: native Windows, failover-able, runs windows-complete. + expect(typeof windows['runs-on']).toBe('string') + expect(windows['runs-on']).toContain('DSH_CI_FAILOVER') + expect(windows['runs-on']).toContain('self-hosted') + expect(windows['runs-on']).toContain('dsh-win-ci') + expect(windows['runs-on']).toContain('dsh-windows-2025-16core') + expect(windows.name).toBe('windows node 24 / native complete') expect(windows.if).toBe("github.event_name == 'pull_request'") - expect(JSON.stringify(windows)).toContain('bash scripts/wine-windows-gates.sh') - expect(workflow.jobs).toHaveProperty('wine-apt-cache') - expect(windowsNative['runs-on']).toBe('dsh-windows-2025-16core') - expect(windowsNative.name).toBe('windows node 24 / native complete') - expect(windowsNative['timeout-minutes']).toBe(60) - expect(windowsNative.if).toBe("github.event_name == 'pull_request'") - expect(windowsNative.env).toMatchObject({ + expect(windows.env).toMatchObject({ DSH_COVERAGE_MAX_WORKERS: '2', DSH_GATE_CONCURRENCY: '2', DSH_PUBLINT_CONCURRENCY: '8', }) - expect(windowsNative).not.toHaveProperty('continue-on-error') - expect(nativeCommandSteps).toHaveLength(3) - expect(nativeCommandSteps.every(step => step.shell === 'pwsh')).toBe(true) - expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') - expect(JSON.stringify(windowsNative)).not.toMatch(/wine/i) + expect(commandSteps.every(step => step.shell === 'pwsh')).toBe(true) + expect(commandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') + const developerMode = windows.steps.find((step): step is Record & { run: string } => ( + isRecord(step) && typeof step.run === 'string' && step.run.includes('AllowDevelopmentWithoutDevLicense') + )) + expect(developerMode).toBeDefined() + + // No Wine or dual-lane remnants. + const workflowJson = JSON.stringify(workflow) + expect(workflowJson).not.toMatch(/wine/i) + expect(workflow.jobs).not.toHaveProperty('windows-native') + expect(workflow.jobs).not.toHaveProperty('wine-apt-cache') + + // serial-windows: master-only standby, self-hosted, non-blocking. + expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") + expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) + expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') + + // Aggregate: windows required, serial-windows excluded. expect(aggregate.needs).toContain('windows') expect(aggregate.needs).not.toContain('windows-native') + expect(aggregate.needs).not.toContain('serial-windows') }) it('keeps supported LSP source under native Windows coverage', () => { diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh deleted file mode 100755 index 5e46b75f91..0000000000 --- a/scripts/wine-windows-gates.sh +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env bash -# Run the blocking Windows gates (workspace build, production site) with real -# win-x64 Node.js under Wine — the same script the pull-request `windows` job -# in ci.yml executes and the optional local gate `pnpm run check:windows-wine` -# wraps. Owning rationale and fidelity limits: -# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -# -# The working tree is never mutated: tracked plus untracked-unignored files -# are snapshotted into a scratch directory, the Wine-specific pnpm overrides -# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's -# pnpm-workspace.yaml, and the install and gates run there against the shared -# pnpm store. The Wine prefix and the checksum-verified Windows Node zip -# persist in .cache/wine-windows/ so reruns skip provisioning. -# -# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24) -# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache; -# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection. - -set -euo pipefail - -repo_root="$(git rev-parse --show-toplevel)" -node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}" -cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}" - -export WINEDEBUG='-all' -export WINEARCH=win64 -# Skip Wine Mono / Gecko installers: Node needs neither. -export WINEDLLOVERRIDES='mscoree,mshtml=' -export WINEPREFIX="$cache_dir/prefix" - -# ---- preflight: fail loud before any expensive work -------------------- -wine_bin='' -for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do - if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi -done -# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both -# accept the same " " --check input. -checksum_tool='' -if command -v sha256sum > /dev/null; then - checksum_tool='sha256sum' -elif command -v shasum > /dev/null; then - checksum_tool='shasum' -fi -missing=() -[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)') -command -v curl > /dev/null || missing+=('curl') -command -v unzip > /dev/null || missing+=('unzip') -[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)') -if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi -command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)') -if (( ${#missing[@]} > 0 )); then - printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2 - exit 1 -fi - -# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found. -verify_sha256() { - case "$checksum_tool" in - sha256sum) printf '%s %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;; - shasum) printf '%s %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;; - esac -} - -scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")" -cleanup() { - wineserver -k > /dev/null 2>&1 || true - if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then - echo "wine-windows-gates: scratch tree kept at $scratch" - else - rm -rf "$scratch" - fi -} -trap cleanup EXIT -mkdir -p "$cache_dir" "$scratch/logs" - -# ---- provision Windows Node, boot Wine, snapshot + install concurrently ---- -curl_metadata_args=( - --fail --silent --show-error --location - --retry 3 --retry-all-errors --retry-delay 2 - --http1.1 --connect-timeout 10 --max-time 30 --retry-max-time 120 -) - -download_node_archive() { - local version="$1" output="$2" attempt status=0 - local archive="node-$version-win-x64.zip" - local primary_url="https://nodejs.org/dist/$version/$archive" - local mirror_url="https://npmmirror.com/mirrors/node/$version/$archive" - - if curl --fail --silent --show-error --location --http1.1 \ - --connect-timeout 10 --max-time 300 --speed-limit 1024 --speed-time 30 \ - -o "$output" "$primary_url"; then - return 0 - fi - echo 'wine-windows-gates: nodejs.org archive transfer stalled; resuming from the checksum-untrusted transport mirror' >&2 - for attempt in 1 2 3; do - if curl --fail --silent --show-error --location --http1.1 \ - --continue-at - --connect-timeout 10 --max-time 300 \ - --speed-limit 1024 --speed-time 30 \ - -o "$output" "$mirror_url"; then - return 0 - else - status=$? - fi - (( attempt < 3 )) || break - echo "wine-windows-gates: mirror transfer failed (exit $status) on attempt $attempt; resuming partial download" >&2 - done - return "$status" -} - -provision_node() { - # Latest release of the primary line, checksum-verified against the same - # dist directory. Bound and retry every transfer so a stalled nodejs.org - # response cannot consume the entire CI job. Offline runs fall back to the - # newest cached zip, loudly. - local version zip - version="$(curl "${curl_metadata_args[@]}" https://nodejs.org/dist/index.json 2> /dev/null \ - | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \ - || true)" - if [ -n "$version" ]; then - zip="$cache_dir/node-$version-win-x64.zip" - if [ ! -f "$zip" ]; then - download_node_archive "$version" "$zip.tmp" - local expected - expected="$(curl "${curl_metadata_args[@]}" "https://nodejs.org/dist/$version/SHASUMS256.txt" \ - | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')" - [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; } - verify_sha256 "$expected" "$zip.tmp" - mv "$zip.tmp" "$zip" - fi - else - zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)" - [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; } - echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2 - fi - unzip -q -o "$zip" -d "$scratch/node-win" - echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path" -} - -boot_wine() { - "$wine_bin" wineboot --init > /dev/null 2>&1 || true - wineserver -w || true -} - -snapshot_and_install() { - # Tracked + untracked-unignored files, minus agent-session litter; the - # existence filter drops paths staged as deleted. Then the Wine-specific - # install-time overrides go on the SNAPSHOT only: hoisted because Windows - # Node under Wine does not realpath pnpm's isolated-layout symlinks, and - # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize. - # Neither is recorded in the lockfile, so --frozen-lockfile stays valid; - # --ignore-scripts skips host lifecycle scripts no gate loads. - git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \ - | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \ - | tar -C "$repo_root" --null --files-from=- -cf - \ - | tar -C "$scratch/tree" -xf - - cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF' - -nodeLinker: hoisted -supportedArchitectures: - os: [current, win32] - cpu: [current, x64] -EOF - # The hoisted linker — used only by this lane — has an upstream rename - # race (pnpm/pnpm#12880): parallel linkers staging a nested package copy - # (observed on the tree's nested esbuild versions) rename their _tmp_* - # directory onto a path another racer already claimed, and the loser - # exits ERR_PNPM_ENOENT although an identical re-install succeeds. - # Exactly that signature earns up to two retries on a clean tree — the - # snapshot contains no node_modules, so wiping them restores the - # pre-install state; any other failure, or the race still standing after - # the final attempt, fails loud with the log tail. - local attempt - for attempt in 1 2 3; do - (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ - && return 0 - grep -q 'ERR_PNPM_ENOENT.*rename.*_tmp_' "$scratch/logs/install.log" || break - (( attempt < 3 )) || break - echo "wine-windows-gates: pnpm hoisted-linker rename race (pnpm/pnpm#12880) on install attempt $attempt; retrying on a clean tree" >&2 - find "$scratch/tree" -name node_modules -type d -prune -exec rm -rf {} + - done - tail -40 "$scratch/logs/install.log" >&2 - return 1 -} - -mkdir "$scratch/tree" -start=$SECONDS -provision_node & node_pid=$! -boot_wine & wine_pid=$! -snapshot_and_install & install_pid=$! -# Wait for EVERY child before judging any: a bare `wait` under set -e would -# exit on the first failure and let the EXIT trap delete $scratch while the -# other children still run inside it. Named statuses also make the report -# point at the root cause instead of a downstream symptom. -node_status=0; wait "$node_pid" || node_status=$? -wine_status=0; wait "$wine_pid" || wine_status=$? -install_status=0; wait "$install_pid" || install_status=$? -provision_failed=0 -report_provision() { - if (( $2 != 0 )); then - echo "wine-windows-gates: FAILED $1 (exit $2)" >&2 - provision_failed=$2 - fi -} -report_provision 'Windows Node provisioning' "$node_status" -report_provision 'wineboot' "$wine_status" -report_provision 'workspace snapshot + pnpm install' "$install_status" -if (( provision_failed != 0 )); then exit "$provision_failed"; fi -node_win="$(cat "$scratch/node-win-path")" -echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))" - -# ---- resolve entrypoints, lay the vue link, smoke ------------------------ -# Node under Wine cannot attach stdio to pipes the caller owns (Socket open -# EBADF at bootstrap), so every invocation routes stdio through a file. -wine_node() { - local log="$1" - shift - local status=0 - "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$? - return "$status" -} - -cd "$scratch/tree" -tsc_js='node_modules/typescript/bin/tsc' -tsdown_js='node_modules/tsdown/dist/run.mjs' -vitepress_js='node_modules/vitepress/bin/vitepress.js' -[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js' -for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do - [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; } -done -# VitePress links vue into the site's node_modules at build time; Wine cannot -# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones. -if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then - mkdir -p website/node_modules - ln -s ../../node_modules/vue website/node_modules/vue -fi - -wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" -cat "$scratch/logs/smoke.log" -grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } - -# ---- the two blocking surfaces, concurrently ------------------------------ -# The build preserves the face order from package.json: compile and bundle the -# Host face before compiling and bundling the Client face. -# Both statuses are captured so one failure cannot hide the other's result. -build_gate() { - wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? - wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? - wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? - wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client -} -site_gate() { - cd website - wine_node "$scratch/logs/site.log" "../$vitepress_js" build . -} - -start=$SECONDS -build_gate & build_pid=$! -site_gate & site_pid=$! -build_status=0 -wait "$build_pid" || build_status=$? -site_status=0 -wait "$site_pid" || site_status=$? -elapsed=$((SECONDS - start)) - -report() { - local label="$1" status="$2" - shift 2 - if (( status == 0 )); then - echo "wine-windows-gates: PASS $label (${elapsed}s window)" - else - echo "== FAILED $label (exit $status) ==" >&2 - for log in "$@"; do tail -n 200 "$log" >&2 || true; done - fi -} -report 'build (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \ - "$scratch/logs/host-tsc.log" \ - "$scratch/logs/host-tsdown.log" \ - "$scratch/logs/client-tsc.log" \ - "$scratch/logs/client-tsdown.log" -report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" -if (( build_status != 0 )); then exit "$build_status"; fi -exit "$site_status" From e47598470654bd3e68d01f79891bdb9719f9613a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:16:34 +0800 Subject: [PATCH 36/67] test(sandbox): close Windows ACL coverage gaps --- .../sandbox/sandbox-windows-acl/src/index.ts | 2 + .../tests/index-failure-paths.spec.ts | 39 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index c3d302c43d..cf304b1904 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -239,6 +239,8 @@ export class AclSandbox { this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid) const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption + /* v8 ignore next -- constructor validation requires workspace-write to supply + an explicit temp directory or null; the other branches normalize to null. */ if (tempDir === undefined) throw new Error('AclSandbox workspace-write temp directory was not resolved') if (tempDir !== null) { if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index d9e5d06ab1..c171d30084 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -186,6 +186,28 @@ describe('AclSandbox constructor validation', () => { expect(sandbox.mode).toBe('read-only') expect(sandbox.tempDir).toBeUndefined() }) + + it('rejects temp authority under read-only', () => { + const workspace = scratch() + const temp = scratch() + expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' })) + .toThrow(/read-only does not accept a temp directory/u) + expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' })) + .toThrow(/read-only does not accept write SIDs/u) + expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' })) + .toThrow(/read-only does not accept write SIDs/u) + }) + + it('rejects a temp SID when temp writes are disabled', () => { + const workspace = scratch() + expect(() => new AclSandbox({ + writableDirs: [workspace], + tempDir: null, + writeSid: 'S-1-4-9000-2', + tempWriteSid: 'S-1-4-9000-2-1', + mode: 'workspace-write', + })).toThrow(/temp write SID requires a temp directory/u) + }) }) describe('AclSandbox init', () => { @@ -274,20 +296,29 @@ describe('AclSandbox init', () => { await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error) }) - it('reports a failed close of the current process token', async () => { - const { closeHandle } = state.stubs as HappyStubs + it('aggregates failed current and restricted token closes after init', async () => { + const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' }) + const restrictedToken = 99n + createRestrictedToken.mockImplementation(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + _rc: unknown, _rs: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, restrictedToken) + return 1 + }) // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the // token-layer close of 1n succeeds and init's close of 2n fails. - closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1)) + closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n || handle === restrictedToken ? 0 : 1)) // The failure lands after this.token is stored but before this.api is // assigned. Cleanup retries the still-open handle and reports both close - // failures after releasing the restricted token and parsed SIDs. + // failures plus the restricted-token close after releasing parsed SIDs. await expect(sandbox.init()).rejects.toMatchObject({ errors: [ { api: 'CloseHandle' }, { api: 'CloseHandle' }, + { api: 'CloseHandle' }, ], }) }) From 23995f88cec8fef41a7545e67c3c22b26ed97eef Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 23:18:26 +0800 Subject: [PATCH 37/67] fix(web): give the protocol dropdowns the shared chevron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `select.input` caps the control at 240px, and a `` that takes `.input` without `.selectInput`, so the next one cannot be added bare. --- ...-a-provider-from-the-models-page.i18n.yaml | 4 ++-- ...claring-a-provider-from-the-models-page.md | 2 +- ...ring-a-provider-from-the-models-page.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 2 +- .../ui-models/src/client/ProviderEditor.tsx | 2 +- .../client/ui-models/tests/styles.spec.ts | 23 +++++++++++++++++++ 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml index bff5cc88fc..79aba9caa4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.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-04-declaring-a-provider-from-the-models-page.md -2026-08-04-declaring-a-provider-from-the-models-page.md: 3c63254364e3803e9bddab2a5aad4c092e4f0426 -2026-08-04-declaring-a-provider-from-the-models-page.zh.md: c38b0387b3c7778b93fb43232c22a7acb24dc281 +2026-08-04-declaring-a-provider-from-the-models-page.md: dc0f6826be4c878c0ae5ffbca076f2b3037f1182 +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 5f25eff196869008d4e0d58776283c70de30e26a diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md index 3c63254364..dc0f6826be 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -48,4 +48,4 @@ What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is th ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename. +`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The stylesheet gate reads the package's own sources and fails any `` 都会失败——否则它保留的系统箭头会紧贴 `select.input` 所设 240px 上限的右边缘。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b8b4459c77..f3c43fcc5a 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -236,7 +236,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
{t('customApi')} { expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/) }) + it('gives every dropdown the shared chevron instead of the OS arrow', () => { + // `select.input` caps the control at 240px, and the OS arrow is painted + // flush inside that shrunk right edge — visibly tighter than every other + // control on the page. `.selectInput` is what removes it, reserves the + // right pad, and paints the shared chevron; a `
diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 7d75e1de11..561a5df494 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -80,6 +80,7 @@ export const en = { customRouteTaken: 'A provider already uses this ID.', customDisplayName: 'Display name', customApi: 'API protocol', + customApiUnset: 'Not selected', customNeedsBaseUrl: 'A custom provider needs a base URL.', customNeedsModels: 'A custom provider needs at least one model.', create: 'Create provider', @@ -173,6 +174,7 @@ export const zh: typeof en = { customRouteTaken: '已有提供方使用了这个 ID。', customDisplayName: '显示名称', customApi: 'API 协议', + customApiUnset: '未选择', customNeedsBaseUrl: '自定义提供方需要填写 API 地址。', customNeedsModels: '自定义提供方至少需要一个模型。', create: '创建提供方', diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 696e2258be..be0f198df7 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from 'schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { ModelsSection } from '../src/client/ModelsSection.tsx' +import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' @@ -47,6 +47,7 @@ function fail(message: string, code: string): RpcResponse { function piAiNamespace( providers: Record, userProviders: Record = providers, + baseProviders: Record = {}, ): SettingsNamespaceView { return { ns: 'llm-pi-ai', @@ -54,7 +55,7 @@ function piAiNamespace( // `value` is the effective section; `user` is only the layer this page // writes. They differ whenever a composition `base` supplies something. value: { providers }, - base: {}, + base: { providers: baseProviders }, user: { providers: userProviders }, applies: 'live', secrets: [], @@ -66,6 +67,8 @@ function scriptedFace(options: { providers?: Record /** User layer, when it differs from the effective section. */ userProviders?: Record + /** Composition layer, for a route a `cordis.yml` pins rather than the page. */ + baseProviders?: Record /** Routes the adapter reports as hand-declared; the rest come back as shipped. */ declaredRoutes?: readonly string[] discover?: ReturnType @@ -75,7 +78,7 @@ function scriptedFace(options: { const providers = options.providers ?? { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' }, } - const namespace = piAiNamespace(providers, options.userProviders ?? providers) + const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {}) const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] }))) const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace))) const set = options.set ?? vi.fn(() => Promise.resolve(ok({}))) @@ -759,6 +762,55 @@ describe('hand-declared providers', () => { .toEqual([{ op: 'set', path: ['providers', 'acme-gateway', 'displayName'], value: 'Acme 网关' }]) }) + it('offers the composition name as what a cleared field falls back to', async () => { + // A `cordis.yml` can pin a route the catalog does not ship, so a declared + // route's profile is not always the page's own. The field edits the user + // layer alone, and clearing it restores the layer beneath — the + // composition name here, not the route id — so that is what it offers. + await mountSection({ + providers: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } }, + baseProviders: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } }, + userProviders: {}, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + + const name = screen.getByLabelText(en.customDisplayName) + expect(name.value).toBe('') + expect(name.placeholder).toBe('Acme (pinned)') + }) + + it('names the provider as the refreshed directory reports it after a rename', async () => { + // The status line used to echo the target captured when the card opened, + // which never lied while the name could not change. It can now. + const { face } = await mountSection({ + providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } }, + declaredRoutes: ['acme-gateway'], + }) + // The reload after the write answers with the renamed route, exactly as + // the adapter re-registers it. + face.llm.providers = vi.fn(() => Promise.resolve(ok({ + providers: [{ + provider: 'acme-gateway', + displayName: 'Acme 网关', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'acme-gateway'], + active: true, + declared: true, + }], + }))) + openEditor('acme-gateway') + + fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme 网关' } }) + fireEvent.click(screen.getByText(en.apply)) + + const notice = await screen.findByRole('status') + expect(notice.textContent).toBe(providerCopy(en.savedProvider, { + provider: 'acme-gateway', + displayName: 'Acme 网关', + })) + }) + it('drops the stored name rather than storing an empty one the adapter refuses', async () => { // `llm-pi-ai` rejects an empty displayName outright, so clearing the field // must unset it — which is also what the user means: use the route id. From 8d7c4380aa22ca5c4300559030a13be3000b4dc7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 23:33:26 +0800 Subject: [PATCH 39/67] fix(apiproxy): ensure the recorded composition before reading the baseline `session.history` built its projection baseline in `historyStateFor` and only then awaited `presenterScopeFor`, which is what ensures the recorded preset's standing mount. The unit table is process-wide while the units themselves are registered by preset rows, so on a first cold read the baseline was serialized before `todos` (and every other preset-owned key) existed, and every later read served a complete one. Until this branch, a web lane happened to mount a preset in `beforeAll` for unrelated reasons, which hid the ordering. The baseline is now a thunk the handler reads after the scope resolves. Also covers the roster-less early return of the unjoined-agent warning, which the per-file coverage gate reported uncovered. --- packages/host/apiproxy/src/api-proxy.ts | 41 ++++++++++++++----- .../preset/agent-presets/tests/mount.spec.ts | 13 ++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index ea41122cc9..34abe41f87 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1329,23 +1329,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return undefined } - /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */ + /** + * Read one transcript cut without acquiring an Agent owner, plus a DEFERRED + * read of the projection baseline. + * + * The baseline is a thunk rather than a value because the unit table is + * process-wide while the units themselves are registered by preset rows: a + * key like `todos` exists only once its preset's standing mount is composed. + * The caller ensures that mount through {@link presenterScopeFor} — which + * needs the header this function returns — so reading the snapshot eagerly + * would serve a first cold read a page missing every preset-owned key, and + * every later read a complete one. + */ async function historyStateFor( sessionId: SessionId, includeProjections: boolean, - ): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> { + ): Promise<{ + header: SessionHeader + events: SessionEvent[] + readProjections: () => SessionProjectionsBlock | undefined + }> { const attached = ctx.sessions.get(sessionId) if (attached !== undefined) { - const events = [...attached.events] - const projections = includeProjections ? projectionsFor(ctx, attached) : undefined - return { header: attached.header, events, ...projections === undefined ? {} : { projections } } + return { + header: attached.header, + events: [...attached.events], + readProjections: () => includeProjections ? projectionsFor(ctx, attached) : undefined, + } } const inspected = await inspectServable(sessionId) - const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined return { header: inspected.meta, events: inspected.events, - ...projections === undefined ? {} : { projections }, + readProjections: () => includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined, } } @@ -2006,7 +2022,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock } + let state: Awaited> try { state = await historyStateFor(sessionId, beforeSeq === undefined) } catch (error: unknown) { @@ -2019,11 +2035,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state)) + // The scope resolves first: ensuring the recorded composition's + // standing mount is what registers its projection units, so the + // baseline below has to be read after it, not beside it. + const scope = await presenterScopeFor(sessionId, state) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, scope) + const projections = state.readProjections() return ok(request, { events: page.events, hasMore: page.hasMore, - ...state.projections === undefined ? {} : { projections: state.projections }, + ...projections === undefined ? {} : { projections }, }) }, diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index b2f37e206e..9f2760e704 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -515,6 +515,19 @@ describe('replacing a composition', () => { expect(warnings).toEqual([]) }) + it('says nothing when the deployment configures no roster at all', async () => { + // Presets are optional: every surface except the Web bundle keeps its + // model-facing rows in the host plane, so an agent with a chain of one is + // exactly right there and the diagnostic must stay silent. + const rosterless = await harness({ default: 'standard', roots: [] }) + const warnings: string[] = [] + rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn + + await rosterless.agents.create({ sessionId: SessionId('sess-no-roster') }) + + expect(warnings).toEqual([]) + }) + it('composes an agent that had nothing installed', async () => { // An agent created without a preset has no binding to re-link, so the // switch is its first bind — exactly a mount — and once bound only the From 060617c725f41bb88b0b71bd4971457d02afaec9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 23:35:58 +0800 Subject: [PATCH 40/67] fix: repair broken cross-links and archived-note sealing after master merge - Fix relative links to archived native-windows-pull-request-ci note (../../archived/ not ../archived/ from implemented/process/) - Replace dangling reference to nonexistent 2026-08-10-unified-windows-selfhosted-ci.md with the archived note - Seal archived note triplet in manifest.json - Update i18n consistency sidecars --- .agents/notes/archived/manifest.json | 3 +++ .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../2026-07-21-serial-cross-platform-ci-reference.i18n.yaml | 4 ++-- .../process/2026-07-21-serial-cross-platform-ci-reference.md | 2 +- .../2026-07-21-serial-cross-platform-ci-reference.zh.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-07-23-portable-required-pull-request-ci.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.zh.md | 2 +- 11 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..b6bb8bb53f 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -301,6 +301,9 @@ "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5", "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936", "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e", + "process/2026-08-08-native-windows-pull-request-ci.i18n.yaml": "sha256:7533de3f9df64b08f57f2010829d6aec5c83a003c22d66c8f31835ee51694767", + "process/2026-08-08-native-windows-pull-request-ci.md": "sha256:9a62d601c9188c9058a19cbe6b03583eed51a2388a2297ad991e5c1727f756c7", + "process/2026-08-08-native-windows-pull-request-ci.zh.md": "sha256:f4ef98c6d57b46b41a251a6957a2f03f317717bee55c3e6984608d94202b5321", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 0a565b17d1..e5c58e9e42 100644 --- a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.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/archived/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 3f69f335f6d873124fbbc1fd96d06ff2b3c8dc43 -2026-08-08-native-windows-pull-request-ci.zh.md: 136a7e8791da141cc12ba77d148f47b28db4790b +2026-08-08-native-windows-pull-request-ci.md: a8c84f768b04b4c93ef16f6e9531d674ff59b8ff +2026-08-08-native-windows-pull-request-ci.zh.md: 178940aaca5680f17c188c9d4e19f2b4f48ed9cb diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index a65b22ba3f..cd17490bb0 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: b7b90f84095fe317f92c051f66da0bf8ee080b19 -2026-07-21-serial-cross-platform-ci-reference.zh.md: d62d7d754eb0343d8b1dd86adae7f501c04b177b +2026-07-21-serial-cross-platform-ci-reference.md: 32e787276628f48c67a76f05c22d7e018590755c +2026-07-21-serial-cross-platform-ci-reference.zh.md: d31c4343a631723ce2ce4311fe44221347078e7c diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index b7b90f8409..32e7872766 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs native on the enterprise larger runner under normal operation, failing over to the self-hosted pool under `DSH_CI_FAILOVER=selfhosted`; the archived [dual Windows decision](../archived/process/2026-08-08-native-windows-pull-request-ci.md) records the prior Wine/native topology. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs native on the enterprise larger runner under normal operation, failing over to the self-hosted pool under `DSH_CI_FAILOVER=selfhosted`; the archived [dual Windows decision](../../archived/process/2026-08-08-native-windows-pull-request-ci.md) records the prior Wine/native topology. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index d62d7d754e..d31c4343a6 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在正常运行下使用企业级大型运行器,在 `DSH_CI_FAILOVER=selfhosted` 时切换至自托管池;已归档的[双 Windows 决策](../archived/process/2026-08-08-native-windows-pull-request-ci.md)记录此前的 Wine/原生拓扑。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在正常运行下使用企业级大型运行器,在 `DSH_CI_FAILOVER=selfhosted` 时切换至自托管池;已归档的[双 Windows 决策](../../archived/process/2026-08-08-native-windows-pull-request-ci.md)记录此前的 Wine/原生拓扑。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 2c61edfac9..cc54e6ee1d 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: 84c951809891b4936549a2f429dc7efc99833c1b -2026-07-22-evidence-based-larger-hosted-runners.zh.md: e097f8b18c7a03a4760e9cb4f45b6385c44b051c +2026-07-22-evidence-based-larger-hosted-runners.md: cf7de074585ea98529ce5f9f1cbadb142944f2ef +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 7d59ac56740be7740dfc30669853ccdc80b7a9ee diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 84c9518098..cf7de07458 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -24,7 +24,7 @@ The gate dependencies remain explicit. Coverage consumes source and does not wai The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails. -Within this enterprise required topology, Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts, while Linux owns the duplicate lint, coverage, and snapshot inventories. The later [dual Windows pull-request topology](2026-08-08-native-windows-pull-request-ci.md) adds a separate non-blocking standard-hosted native job that independently enforces supported-source coverage without extending this paid required path. +Within this enterprise required topology, Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts, while Linux owns the duplicate lint, coverage, and snapshot inventories. The later [dual Windows pull-request topology](../../archived/process/2026-08-08-native-windows-pull-request-ci.md) adds a separate non-blocking standard-hosted native job that independently enforces supported-source coverage without extending this paid required path. An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index e097f8b18c..7d59ac5674 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -24,7 +24,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布约定只要遗漏一个运行时分片,检查仍会失败。 -在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建、生产网站与观测性构建产物约定,重复的 lint、覆盖率和快照清单则由 Linux 负责。后续的[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径。 +在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建、生产网站与观测性构建产物约定,重复的 lint、覆盖率和快照清单则由 Linux 负责。后续的[拉取请求双 Windows 拓扑](../../archived/process/2026-08-08-native-windows-pull-request-ci.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径。 一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 4738f87fda..3fc548edab 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 6f7a0e0e6354e9bea632d1b00881a44acd921f9e -2026-07-23-portable-required-pull-request-ci.zh.md: 0ec92f11efa55ed155ada1ba858cd1878398f1ec +2026-07-23-portable-required-pull-request-ci.md: 175a849f2cdc8f86e244c034c3aaf68a8fa44307 +2026-07-23-portable-required-pull-request-ci.zh.md: 1dec93aca6f12b9c366ceedc78ce47f76b57d509 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 6f7a0e0e63..175a849f2c 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs native on the enterprise larger runner under normal operation, failing over to the self-hosted pool under `DSH_CI_FAILOVER=selfhosted` ([unified Windows CI note](2026-08-10-unified-windows-selfhosted-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial cross-platform standbys continuously validate the failover targets. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs native on the enterprise larger runner under normal operation, failing over to the self-hosted pool under `DSH_CI_FAILOVER=selfhosted` ([unified Windows CI note](../../archived/process/2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial cross-platform standbys continuously validate the failover targets. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / native complete` remain dependencies of `all checks passed`. The master-only `serial / windows (self-hosted standby)` is deliberately absent from the aggregate, mirroring the Linux standby pattern. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index 0ec92f11ef..1dec93aca6 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在正常运行下使用企业级大型运行器,在 `DSH_CI_FAILOVER=selfhosted` 时切换至自托管池([统一 Windows CI 笔记](2026-08-10-unified-windows-selfhosted-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,跨平台串行备援持续验证故障切换目标。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在正常运行下使用企业级大型运行器,在 `DSH_CI_FAILOVER=selfhosted` 时切换至自托管池([统一 Windows CI 笔记](../../archived/process/2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,跨平台串行备援持续验证故障切换目标。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / native complete` 继续作为 `all checks passed` 的依赖项;仅 master 触发的 `serial / windows (self-hosted standby)` 被刻意排除,与 Linux 备援模式对称。 From 4b37c4827c21ee6ba21e3728e823d8212de070fa Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 01:48:37 +0800 Subject: [PATCH 41/67] ci: keep Wine required, add failover to native Windows, enable serial-windows standby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the Wine-emulated windows job as the required pull-request Windows signal. Add DSH_CI_FAILOVER switch to windows-native so it retargets onto the self-hosted [self-hosted, dsh-win-ci, windows] pool under failover. Enable serial-windows as a master-only self-hosted standby mirroring serial-linux-selfhosted. The windows-native job remains absent from all-checks-passed.needs — Wine owns the required verdict, native Windows reports independently. Restore wine-apt-cache, wine-windows-gates.sh, check:windows-wine script, and the original Agent Note topology. --- ...-08-08-dsh-run-headless-command.i18n.yaml} | 6 +- .../2026-08-08-dsh-run-headless-command.md | 44 +++ .../2026-08-08-dsh-run-headless-command.zh.md | 44 +++ .agents/notes/archived/manifest.json | 6 +- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +- ...7-21-serial-cross-platform-ci-reference.md | 4 +- ...1-serial-cross-platform-ci-reference.zh.md | 4 +- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- ...ortable-required-pull-request-ci.i18n.yaml | 4 +- ...07-23-portable-required-pull-request-ci.md | 6 +- ...23-portable-required-pull-request-ci.zh.md | 6 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 8 +- .../2026-07-26-ci-failover-runbook.zh.md | 8 +- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 4 +- ...ction-setup-for-symmetric-ci-caching.zh.md | 4 +- ...8-native-windows-pull-request-ci.i18n.yaml | 6 + ...26-08-08-native-windows-pull-request-ci.md | 1 - ...08-08-native-windows-pull-request-ci.zh.md | 1 - .github/AGENTS.md | 2 +- .github/workflows/ci.yml | 125 +++++++- AGENTS.md | 1 + package.json | 1 + scripts/ci-workflow.spec.ts | 53 ++-- scripts/wine-windows-gates.sh | 282 ++++++++++++++++++ 28 files changed, 561 insertions(+), 79 deletions(-) rename .agents/notes/archived/{process/2026-08-08-native-windows-pull-request-ci.i18n.yaml => feature/2026-08-08-dsh-run-headless-command.i18n.yaml} (56%) create mode 100644 .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md create mode 100644 .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md create mode 100644 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml rename .agents/notes/{archived => implemented}/process/2026-08-08-native-windows-pull-request-ci.md (99%) rename .agents/notes/{archived => implemented}/process/2026-08-08-native-windows-pull-request-ci.zh.md (99%) create mode 100755 scripts/wine-windows-gates.sh diff --git a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml similarity index 56% rename from .agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml index e5c58e9e42..d07841d540 100644 --- a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: a8c84f768b04b4c93ef16f6e9531d674ff59b8ff -2026-08-08-native-windows-pull-request-ci.zh.md: 178940aaca5680f17c188c9d4e19f2b4f48ed9cb +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: ce9cff965192357022c49655983fe6ff8d554b9f +2026-08-08-dsh-run-headless-command.zh.md: 0484c069ed6365235e4616542a4fb3d5ceb2d880 diff --git a/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md new file mode 100644 index 0000000000..ce9cff9651 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md @@ -0,0 +1,44 @@ +# Agent Note: `dsh run` owns one-shot headless execution + +Status: implemented +Archived: 2026-08-10 + +English | [中文](2026-08-08-dsh-run-headless-command.zh.md) + +> **Superseded command grammar.** [Apps now own their command lines](../architecture/2026-08-06-app-owned-command-line.md): the headless startup row parses the task from `dsh --profile headless `, and the launcher no longer has a `run` invocation or patches task text into rows. This note remains the rejected launcher-owned design context; the direct execution and completion contract it selected remains current in [headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md). + +## Problem + +Generic profile boot and one-shot task execution have different lifecycle contracts. A root grammar that accepts optional task text makes one argv shape mean either a long-lived process or a terminating task according to a plugin row discovered only after composition. It also exposes a profile implementation detail as the primary user command and gives custom profiles no explicit one-shot entry. + +The `run` verb must have one top-level meaning. Sharing it with application-file execution or inferring its meaning from positional shape creates the same ambiguity. + +## Decision + +One-shot execution owns this grammar: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` defaults to `headless` and supports custom one-shot compositions. `--patch` is repeatable and occupies the normal overlay layer. Commander joins the variadic task arguments with spaces and rejects a missing or blank task before boot. + +`RunInvocation` is a distinct `DshInvocation` member. The generic profile invocation carries no task state and accepts no positional arguments. Both dispatch paths use `runProfile`: profile boot omits `task`, while `run` supplies it. A one-shot profile without `headless-runner` fails through the composed-row check, and profile boot containing that row without a task points to `dsh run --profile ""`. + +The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. + +The `run` verb belongs only to one-shot task execution. Application-file launch requires a distinct command name. + +## Alternatives considered + +| Alternative | Contract mismatch | +|---|---| +| Put task text on root profile boot | Lifecycle meaning depends on a plugin row discovered after parsing. | +| Accept root aliases such as `dsh -p` | The pre-release grammar acquires compatibility branches with no current command ownership. | +| Require `--profile headless` | The shipped one-shot surface loses its shortest canonical spelling. | +| Use `dsh run` for application files | One top-level verb has two meanings and the primary task command becomes indirect. | +| Add a shallow `apps/cli/src/run.ts` forwarder | Command ownership splits without hiding any complexity. | + +## Consequences + +Help, documentation, parser tests, built-bin acceptance, PTY shutdown coverage, and the assembled keyless snapshot use `dsh run`. Custom one-shot profiles use `--profile`; long-lived profile boot and config dumps keep the root profile grammar. Application-file execution is a separate command concern. diff --git a/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md new file mode 100644 index 0000000000..0484c069ed --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -0,0 +1,44 @@ +# Agent Note: `dsh run` 负责一次性 headless 执行 + +Status: implemented +Archived: 2026-08-10 + +[English](2026-08-08-dsh-run-headless-command.md) | 中文 + +> **命令语法已被取代。** [应用现在持有自己的命令行](../architecture/2026-08-06-app-owned-command-line.md):headless 启动行从 `dsh --profile headless ` 解析任务,启动器不再包含 `run` 调用,也不再把任务文本 patch 进配置行。本笔记保留被否决的启动器持有设计背景;它选定的直接执行与完成约定仍由 [headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)持有。 + +## 问题 + +通用 profile 启动与一次性任务执行具有不同的生命周期约定。若根语法接受可选任务文本,同一种 argv 形态会表示常驻进程或终止式任务,具体含义取决于组合完成后才发现的插件配置行。它还会把 profile 实现细节暴露成主要用户命令,并使自定义 profile 缺少明确的一次性入口。 + +`run` 动词必须只有一种顶层含义。与应用文件执行共用该动词,或根据位置参数形态推断含义,都会产生相同的歧义。 + +## 决策 + +一次性执行采用以下语法: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` 默认为 `headless`,并支持自定义一次性组合。`--patch` 可重复使用,并占据正常的 overlay 层。Commander 用空格拼接可变数量的任务参数,并在启动前拒绝缺失或空白任务。 + +`RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不携带任务状态,也不接受位置参数。两条分派路径都使用 `runProfile`:profile 启动省略 `task`,而 `run` 提供该字段。缺少 `headless-runner` 的一次性 profile 会触发组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 + +[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 + +`run` 动词只负责一次性任务执行。应用文件启动需要不同的命令名。 + +## 考虑过的替代方案 + +| 替代方案 | 约定不匹配之处 | +|---|---| +| 把任务文本放在根 profile 启动命令上 | 生命周期含义依赖解析后才发现的插件配置行。 | +| 接受 `dsh -p` 等根命令别名 | 预发布语法获得不属于任何当前命令的兼容分支。 | +| 要求指定 `--profile headless` | 随附的一次性 surface 失去最短的规范写法。 | +| 将 `dsh run` 用于应用文件 | 一个顶层动词具有两种含义,主要任务命令也变得间接。 | +| 添加仅转发的 `apps/cli/src/run.ts` | 命令归属被拆分,却没有隐藏任何复杂度。 | + +## 后果 + +帮助信息、文档、解析器测试、构建后二进制验收、PTY 关闭覆盖和组装应用的无密钥快照均使用 `dsh run`。自定义一次性 profile 使用 `--profile`;常驻 profile 启动与配置 dump 使用根 profile 语法。应用文件执行是独立的命令关注点。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index b6bb8bb53f..d3d1795489 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -253,6 +253,9 @@ "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", + "feature/2026-08-08-dsh-run-headless-command.i18n.yaml": "sha256:1c2b4c5b61b9263b6267275d6fc69faeaad3cc887f0728a7ed4172d817af812b", + "feature/2026-08-08-dsh-run-headless-command.md": "sha256:7695fe7fd322377d5986f14e35f13337f4cd376405c758218a81230f6d182d1c", + "feature/2026-08-08-dsh-run-headless-command.zh.md": "sha256:113c14a36c64d2facc8ae46f37c7aa76359d8cacb9c18fcba26a723f15d036fb", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", @@ -301,9 +304,6 @@ "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5", "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936", "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e", - "process/2026-08-08-native-windows-pull-request-ci.i18n.yaml": "sha256:7533de3f9df64b08f57f2010829d6aec5c83a003c22d66c8f31835ee51694767", - "process/2026-08-08-native-windows-pull-request-ci.md": "sha256:9a62d601c9188c9058a19cbe6b03583eed51a2388a2297ad991e5c1727f756c7", - "process/2026-08-08-native-windows-pull-request-ci.zh.md": "sha256:f4ef98c6d57b46b41a251a6957a2f03f317717bee55c3e6984608d94202b5321", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index cd17490bb0..aa3cabead0 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 32e787276628f48c67a76f05c22d7e018590755c -2026-07-21-serial-cross-platform-ci-reference.zh.md: d31c4343a631723ce2ce4311fe44221347078e7c +2026-07-21-serial-cross-platform-ci-reference.md: c2ed11d40f7f5487117b5c72f11bc1709042f68a +2026-07-21-serial-cross-platform-ci-reference.zh.md: bef8c3b640cf43942e380e921d5f62d91723eff8 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 32e7872766..c2ed11d40f 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and native Windows jobs plus the Node compatibility and Python contracts. On a push to `master`, the active references are `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` on the in-house self-hosted pools — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs native on the enterprise larger runner under normal operation, failing over to the self-hosted pool under `DSH_CI_FAILOVER=selfhosted`; the archived [dual Windows decision](../../archived/process/2026-08-08-native-windows-pull-request-ci.md) records the prior Wine/native topology. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index d31c4343a6..bef8c3b640 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求运行合并后的 Linux 与原生 Windows 作业,以及 Node 兼容性与 Python 约定。向 `master` 推送时,当前启用的参考作业是公司自有自托管池上的 `serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在正常运行下使用企业级大型运行器,在 `DSH_CI_FAILOVER=selfhosted` 时切换至自托管池;已归档的[双 Windows 决策](../../archived/process/2026-08-08-native-windows-pull-request-ci.md)记录此前的 Wine/原生拓扑。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index cc54e6ee1d..2c61edfac9 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: cf7de074585ea98529ce5f9f1cbadb142944f2ef -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 7d59ac56740be7740dfc30669853ccdc80b7a9ee +2026-07-22-evidence-based-larger-hosted-runners.md: 84c951809891b4936549a2f429dc7efc99833c1b +2026-07-22-evidence-based-larger-hosted-runners.zh.md: e097f8b18c7a03a4760e9cb4f45b6385c44b051c diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index cf7de07458..84c9518098 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -24,7 +24,7 @@ The gate dependencies remain explicit. Coverage consumes source and does not wai The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails. -Within this enterprise required topology, Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts, while Linux owns the duplicate lint, coverage, and snapshot inventories. The later [dual Windows pull-request topology](../../archived/process/2026-08-08-native-windows-pull-request-ci.md) adds a separate non-blocking standard-hosted native job that independently enforces supported-source coverage without extending this paid required path. +Within this enterprise required topology, Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts, while Linux owns the duplicate lint, coverage, and snapshot inventories. The later [dual Windows pull-request topology](2026-08-08-native-windows-pull-request-ci.md) adds a separate non-blocking standard-hosted native job that independently enforces supported-source coverage without extending this paid required path. An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 7d59ac5674..e097f8b18c 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -24,7 +24,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布约定只要遗漏一个运行时分片,检查仍会失败。 -在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建、生产网站与观测性构建产物约定,重复的 lint、覆盖率和快照清单则由 Linux 负责。后续的[拉取请求双 Windows 拓扑](../../archived/process/2026-08-08-native-windows-pull-request-ci.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径。 +在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建、生产网站与观测性构建产物约定,重复的 lint、覆盖率和快照清单则由 Linux 负责。后续的[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径。 一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 3fc548edab..e67565f381 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 175a849f2cdc8f86e244c034c3aaf68a8fa44307 -2026-07-23-portable-required-pull-request-ci.zh.md: 1dec93aca6f12b9c366ceedc78ce47f76b57d509 +2026-07-23-portable-required-pull-request-ci.md: b7c637408afa32611c4e70073ddec9aaabfba7f5 +2026-07-23-portable-required-pull-request-ci.zh.md: ee88412d982da5de613d8dfdbdcdd9977c899d2d diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 175a849f2c..b7c637408a 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs native on the enterprise larger runner under normal operation, failing over to the self-hosted pool under `DSH_CI_FAILOVER=selfhosted` ([unified Windows CI note](../../archived/process/2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial cross-platform standbys continuously validate the failover targets. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. -The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / native complete` remain dependencies of `all checks passed`. The master-only `serial / windows (self-hosted standby)` is deliberately absent from the aggregate, mirroring the Linux standby pattern. +The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. @@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) ## Consequences -Ordinary pull requests spend enterprise capacity on the Linux critical path while the native Windows job uses the enterprise larger runner on the normal path and the self-hosted pool under failover. A live exact-head run distinguishes the commands branch protection consumes from the separate diagnostic contract; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. +Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine job keeps the required Windows verdict on standard Linux allocation. The independent native job uses standard Windows allocation without delaying or changing the aggregate. A live exact-head run distinguishes the commands branch protection consumes from the separate diagnostic contract; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval. Standard compatibility, required Wine, and diagnostic native Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index 1dec93aca6..ee88412d98 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在正常运行下使用企业级大型运行器,在 `DSH_CI_FAILOVER=selfhosted` 时切换至自托管池([统一 Windows CI 笔记](../../archived/process/2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,跨平台串行备援持续验证故障切换目标。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 -两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / native complete` 继续作为 `all checks passed` 的依赖项;仅 master 触发的 `serial / windows (self-hosted standby)` 被刻意排除,与 Linux 备援模式对称。 +两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而原生 Windows 作业在正常运行下使用企业级大型运行器、故障切换时使用自托管池。独立原生作业使用标准 Windows 运行器容量,不会延迟或改变聚合流程。一次实际的分支头精确运行会区分分支保护采用的命令与单独的诊断约定;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 +普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 作业让必需的 Windows 判定继续使用标准 Linux 运行器容量。独立原生作业使用标准 Windows 运行器容量,不会延迟或改变聚合流程。一次实际的分支头精确运行会区分分支保护采用的命令与单独的诊断约定;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。 企业级运行器分配能力下降时,标准兼容性作业、必需的 Wine 作业与诊断性原生 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 2403d869c6..f4fe0c74d2 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 25bfb8cc7ca6d53c5d28e7c941facdb86e5e147b -2026-07-26-ci-failover-runbook.zh.md: 22195500f5a28c9e92bc8aac8c58028628ab1d6b +2026-07-26-ci-failover-runbook.md: 72261f95ea74b61e3915a1a6419b2c2e616efbd9 +2026-07-26-ci-failover-runbook.zh.md: 1ee679eabc296ab31d71b87945409788539425bd diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 25bfb8cc7c..72261f95ea 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -6,20 +6,16 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md) ## Problem -The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. The required Windows job (`windows node 24 / native complete`) runs on the hosted enterprise larger runner. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage AND a hosted Windows-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`. An outage therefore needs a switch any responder with repository write access can throw without merging anything. +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`, `windows`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`, and only the Windows leg has no in-house substitute at all — during the 2026-07-27 outage the standard pools recovered first, which is the ordering this design bets on. An outage therefore needs a switch any responder with repository write access can throw without merging anything. ## Decision -Each of the three required Linux worker jobs, the required Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all five retarget onto the in-house self-hosted pools: the Linux jobs and verdict onto the `vm-backup` pool (`[self-hosted, linux, x64, vm-backup]`), coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped; the Windows job onto the `dsh-win-ci` pool (`[self-hosted, dsh-win-ci, windows]`). The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push. +Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. ### What the in-house pool is `vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite. -#### Windows pool - -`dsh-win-ci`: 16 always-on runner instances on the in-house Windows CI server. Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git, and enable Developer Mode for symlink support; Playwright Chromium is installed per-run by the `serial / windows (self-hosted standby)` lane. Check the latest `serial / windows (self-hosted standby)` run before switching: a green standby verifies the pool can execute `check:ci:windows-complete` end-to-end. - ### Switch (any repository writer, ~1 minute, no merge) 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 22195500f5..1ee679eabc 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -6,20 +6,16 @@ Status: implemented ## 问题 -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。必需的 Windows 作业(`windows node 24 / native complete`)运行在托管的企业级大型运行器上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障和托管 Windows 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`、`windows`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`,且只有 Windows 这条腿完全没有自有替代——2026-07-27 的故障中标准池率先恢复,本设计押注的正是这一顺序。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 ## 决策 -三个必需的 Linux 工作作业、必需的 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,五者全部切换到公司自有的自托管池:Linux 作业与判定位移入 `vm-backup` 池(`[self-hosted, linux, x64, vm-backup]`),覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;Windows 作业移入 `dsh-win-ci` 池(`[self-hosted, dsh-win-ci, windows]`)。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 和 `serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 +三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 ### 自有池是什么 `vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。 -#### Windows 池 - -`dsh-win-ci`:公司自有 Windows CI 服务器上的 16 个常驻运行器实例。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git,并启用开发人员模式以支持符号链接;Playwright Chromium 由 `serial / windows (self-hosted standby)` 通道在每次运行时安装。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:绿色热备验证该池可完整执行 `check:ci:windows-complete`。 - ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index b619bdc1d8..c0a7dcfc02 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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-07-26-pnpm-action-setup-for-symmetric-ci-caching.md -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 612c42d19e3891f3fa036fea74b36e36da2fb5cd -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 7c82c787f11e93cbb62209d25df741d0abeba3dd +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 499141ca6a3703a12d10c195b732441d49419599 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: c57b7d32cd7204a845ab1884e9ae0b9045749a1c diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 612c42d19e..499141ca6a 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -13,8 +13,8 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. -- **Cache-less or persistent** (no store-cache action): the required native Windows job, serial-windows standby, serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 7c82c787f1..c57b7d32cd 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -13,8 +13,8 @@ Status: implemented `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 -- **无缓存或持久化**(不使用 store 缓存 action):必需的 Windows 作业、serial-windows 热备、serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml new file mode 100644 index 0000000000..dcdbff1208 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +2026-08-08-native-windows-pull-request-ci.md: 33fbf1ae378112b4fd82633a77afa52056d93d98 +2026-08-08-native-windows-pull-request-ci.zh.md: 552e5cd3129011198fe442ba747cf2fdb7d97365 diff --git a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md similarity index 99% rename from .agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.md rename to .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index a8c84f768b..33fbf1ae37 100644 --- a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -1,7 +1,6 @@ # Agent Note: Dual Wine and native Windows pull-request CI Status: implemented -Archived: 2026-08-10 English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) diff --git a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md similarity index 99% rename from .agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.zh.md rename to .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 178940aaca..552e5cd312 100644 --- a/.agents/notes/archived/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -1,7 +1,6 @@ # Agent Note: Wine 与原生 Windows 双通道拉取请求 CI Status: implemented -Archived: 2026-08-10 [English](2026-08-08-native-windows-pull-request-ci.md) | 中文 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index ad2aeac354..3e879db8ae 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run Windows CI jobs under native `pwsh`. The required pull-request `windows` job and the master `serial-windows` standby both run on real Windows: normally on the hosted enterprise runner, failing over to the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under `DSH_CI_FAILOVER=selfhosted`. +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` (or the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under `DSH_CI_FAILOVER=selfhosted`) but reports independently. The master `serial-windows` standby continuously validates the self-hosted failover target — see the [failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81a8ffc53f..1249439fde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,12 +300,123 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # Required pull-request Windows signal on real Windows. Under normal - # operation this runs on the hosted larger runner; under failover - # (DSH_CI_FAILOVER=selfhosted) it retargets onto the in-house self-hosted - # Windows pool. Dependabot PRs are excluded from the self-hosted pool and - # stay queued for the hosted runner — see the failover runbook. + # The required pull-request Windows signal: the two blocking win32 surfaces + # (workspace build, production site) execute with real, checksum-verified + # Windows Node under Wine on standard hosted Linux. The independent + # windows-native job below keeps the complete native-kernel inventory — + # including the observational portability gates this lane does not run — + # on real Windows. This job only provisions runner state (caches, + # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same + # script the optional local gate `pnpm run check:windows-wine` runs. + # Current topology and fidelity limits live in + # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md windows: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + name: windows node 24 / wine blocking + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + # Master's wine-apt-cache job seeds the default-branch scope every pull + # request can read; a save from this job only reaches reruns of the + # same merge ref. + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + # Runner provisioning only — a developer machine installs Wine through + # its own package manager; the gate script assumes a wine binary and + # fails loud without one. Wine from the apt cache when present; else + # download the full dependency closure once and keep it for the next + # run. The `wine` dispatcher package (not bare `wine64`) is what puts a + # binary on PATH. + - name: Install Wine + run: | + if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true + sudo apt-get install -y --no-install-recommends wine + fi + + - name: Run the Wine Windows gates + run: bash scripts/wine-windows-gates.sh + + - name: Shut down wineserver + if: always() + run: wineserver -k 2>/dev/null || true + + # Master seeds the Wine apt-archive cache in the default-branch scope, + # which every pull request's windows job can restore; saves from + # pull-request runs are scoped to their own merge ref and help nobody + # else. Runs in seconds when the image version already has a cache. + wine-apt-cache: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: wine apt cache + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Compose Wine apt cache key + id: wine-cache-key + run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + id: wine-cache + with: + path: ~/wine-debs + key: ${{ steps.wine-cache-key.outputs.key }} + + - name: Download the Wine dependency closure + if: steps.wine-cache.outputs.cache-hit != 'true' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" + du -sh "$HOME/wine-debs" + + # Every pull request also gets a real Windows-kernel signal. This job keeps + # its own unmasked conclusion but is deliberately absent from + # all-checks-passed.needs, so it never delays or changes that required + # verdict. Under normal operation it runs on the hosted larger runner; under + # failover (DSH_CI_FAILOVER=selfhosted) it retargets onto the in-house + # self-hosted Windows pool. Dependabot PRs are excluded from the self-hosted + # pool and stay queued for the hosted runner — see the failover runbook. + windows-native: if: github.event_name == 'pull_request' runs-on: >- ${{ vars.DSH_CI_FAILOVER == 'selfhosted' @@ -765,7 +876,9 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`. (`needs` cannot reach across workflow files; e2e.yml stays its own + # `needs`. The required Wine job is listed as `windows`; `windows-native` is + # deliberately absent so its independent result never delays or changes this + # verdict. (`needs` cannot reach across workflow files; e2e.yml stays its own # check.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing diff --git a/AGENTS.md b/AGENTS.md index 48d915344e..f6c75a7372 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check +pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) diff --git a/package.json b/package.json index b1ef084671..3fd0255f98 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", "check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers", + "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index b6d7d858ff..3731017029 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -27,16 +27,20 @@ describe('CI workflow', () => { } }) - it('keeps a single required native Windows job with failover and a master-only standby', () => { + it('keeps a required Wine Windows job, a non-blocking native Windows job with failover, and a master-only standby', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.windows) + || !isRecord(workflow.jobs['windows-native']) + || !isRecord(workflow.jobs['wine-apt-cache']) || !isRecord(workflow.jobs['serial-windows']) || !isRecord(workflow.jobs['all-checks-passed'])) { - throw new TypeError('CI workflow must define windows, serial-windows, and all-checks-passed jobs') + throw new TypeError('CI workflow must define windows, windows-native, wine-apt-cache, serial-windows, and all-checks-passed jobs') } const windows = workflow.jobs.windows + const windowsNative = workflow.jobs['windows-native'] + const wineAptCache = workflow.jobs['wine-apt-cache'] const serialWindows = workflow.jobs['serial-windows'] const aggregate = workflow.jobs['all-checks-passed'] if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) { @@ -46,38 +50,35 @@ describe('CI workflow', () => { isRecord(step) && typeof step.run === 'string' )) - // Required PR job: native Windows, failover-able, runs windows-complete. - expect(typeof windows['runs-on']).toBe('string') - expect(windows['runs-on']).toContain('DSH_CI_FAILOVER') - expect(windows['runs-on']).toContain('self-hosted') - expect(windows['runs-on']).toContain('dsh-win-ci') - expect(windows['runs-on']).toContain('dsh-windows-2025-16core') - expect(windows.name).toBe('windows node 24 / native complete') + // Required PR job: Wine on ubuntu-latest, runs wine-windows-gates.sh. + expect(windows['runs-on']).toBe('ubuntu-latest') + expect(windows.name).toBe('windows node 24 / wine blocking') expect(windows.if).toBe("github.event_name == 'pull_request'") - expect(windows.env).toMatchObject({ - DSH_COVERAGE_MAX_WORKERS: '2', - DSH_GATE_CONCURRENCY: '2', - DSH_PUBLINT_CONCURRENCY: '8', - }) - expect(commandSteps.every(step => step.shell === 'pwsh')).toBe(true) - expect(commandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') - const developerMode = windows.steps.find((step): step is Record & { run: string } => ( - isRecord(step) && typeof step.run === 'string' && step.run.includes('AllowDevelopmentWithoutDevLicense') - )) - expect(developerMode).toBeDefined() + expect(commandSteps.some(step => step.run.includes('wine-windows-gates.sh'))).toBe(true) - // No Wine or dual-lane remnants. - const workflowJson = JSON.stringify(workflow) - expect(workflowJson).not.toMatch(/wine/i) - expect(workflow.jobs).not.toHaveProperty('windows-native') - expect(workflow.jobs).not.toHaveProperty('wine-apt-cache') + // windows-native: non-blocking native job with failover, runs windows-complete. + expect(typeof windowsNative['runs-on']).toBe('string') + expect(windowsNative['runs-on']).toContain('DSH_CI_FAILOVER') + expect(windowsNative['runs-on']).toContain('self-hosted') + expect(windowsNative['runs-on']).toContain('dsh-win-ci') + expect(windowsNative['runs-on']).toContain('dsh-windows-2025-16core') + expect(windowsNative.name).toBe('windows node 24 / native complete') + expect(windowsNative.if).toBe("github.event_name == 'pull_request'") + const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( + isRecord(step) && typeof step.run === 'string' + )) + expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') + + // wine-apt-cache: master-only, seeds the Wine apt cache. + expect(wineAptCache.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") + expect(wineAptCache['runs-on']).toBe('ubuntu-latest') // serial-windows: master-only standby, self-hosted, non-blocking. expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') - // Aggregate: windows required, serial-windows excluded. + // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') expect(aggregate.needs).not.toContain('windows-native') expect(aggregate.needs).not.toContain('serial-windows') diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh new file mode 100755 index 0000000000..5e46b75f91 --- /dev/null +++ b/scripts/wine-windows-gates.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Run the blocking Windows gates (workspace build, production site) with real +# win-x64 Node.js under Wine — the same script the pull-request `windows` job +# in ci.yml executes and the optional local gate `pnpm run check:windows-wine` +# wraps. Owning rationale and fidelity limits: +# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +# +# The working tree is never mutated: tracked plus untracked-unignored files +# are snapshotted into a scratch directory, the Wine-specific pnpm overrides +# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's +# pnpm-workspace.yaml, and the install and gates run there against the shared +# pnpm store. The Wine prefix and the checksum-verified Windows Node zip +# persist in .cache/wine-windows/ so reruns skip provisioning. +# +# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24) +# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache; +# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection. + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}" +cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}" + +export WINEDEBUG='-all' +export WINEARCH=win64 +# Skip Wine Mono / Gecko installers: Node needs neither. +export WINEDLLOVERRIDES='mscoree,mshtml=' +export WINEPREFIX="$cache_dir/prefix" + +# ---- preflight: fail loud before any expensive work -------------------- +wine_bin='' +for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi +done +# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both +# accept the same " " --check input. +checksum_tool='' +if command -v sha256sum > /dev/null; then + checksum_tool='sha256sum' +elif command -v shasum > /dev/null; then + checksum_tool='shasum' +fi +missing=() +[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)') +command -v curl > /dev/null || missing+=('curl') +command -v unzip > /dev/null || missing+=('unzip') +[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)') +if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi +command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)') +if (( ${#missing[@]} > 0 )); then + printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found. +verify_sha256() { + case "$checksum_tool" in + sha256sum) printf '%s %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;; + shasum) printf '%s %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;; + esac +} + +scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")" +cleanup() { + wineserver -k > /dev/null 2>&1 || true + if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then + echo "wine-windows-gates: scratch tree kept at $scratch" + else + rm -rf "$scratch" + fi +} +trap cleanup EXIT +mkdir -p "$cache_dir" "$scratch/logs" + +# ---- provision Windows Node, boot Wine, snapshot + install concurrently ---- +curl_metadata_args=( + --fail --silent --show-error --location + --retry 3 --retry-all-errors --retry-delay 2 + --http1.1 --connect-timeout 10 --max-time 30 --retry-max-time 120 +) + +download_node_archive() { + local version="$1" output="$2" attempt status=0 + local archive="node-$version-win-x64.zip" + local primary_url="https://nodejs.org/dist/$version/$archive" + local mirror_url="https://npmmirror.com/mirrors/node/$version/$archive" + + if curl --fail --silent --show-error --location --http1.1 \ + --connect-timeout 10 --max-time 300 --speed-limit 1024 --speed-time 30 \ + -o "$output" "$primary_url"; then + return 0 + fi + echo 'wine-windows-gates: nodejs.org archive transfer stalled; resuming from the checksum-untrusted transport mirror' >&2 + for attempt in 1 2 3; do + if curl --fail --silent --show-error --location --http1.1 \ + --continue-at - --connect-timeout 10 --max-time 300 \ + --speed-limit 1024 --speed-time 30 \ + -o "$output" "$mirror_url"; then + return 0 + else + status=$? + fi + (( attempt < 3 )) || break + echo "wine-windows-gates: mirror transfer failed (exit $status) on attempt $attempt; resuming partial download" >&2 + done + return "$status" +} + +provision_node() { + # Latest release of the primary line, checksum-verified against the same + # dist directory. Bound and retry every transfer so a stalled nodejs.org + # response cannot consume the entire CI job. Offline runs fall back to the + # newest cached zip, loudly. + local version zip + version="$(curl "${curl_metadata_args[@]}" https://nodejs.org/dist/index.json 2> /dev/null \ + | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \ + || true)" + if [ -n "$version" ]; then + zip="$cache_dir/node-$version-win-x64.zip" + if [ ! -f "$zip" ]; then + download_node_archive "$version" "$zip.tmp" + local expected + expected="$(curl "${curl_metadata_args[@]}" "https://nodejs.org/dist/$version/SHASUMS256.txt" \ + | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')" + [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; } + verify_sha256 "$expected" "$zip.tmp" + mv "$zip.tmp" "$zip" + fi + else + zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)" + [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; } + echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2 + fi + unzip -q -o "$zip" -d "$scratch/node-win" + echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path" +} + +boot_wine() { + "$wine_bin" wineboot --init > /dev/null 2>&1 || true + wineserver -w || true +} + +snapshot_and_install() { + # Tracked + untracked-unignored files, minus agent-session litter; the + # existence filter drops paths staged as deleted. Then the Wine-specific + # install-time overrides go on the SNAPSHOT only: hoisted because Windows + # Node under Wine does not realpath pnpm's isolated-layout symlinks, and + # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize. + # Neither is recorded in the lockfile, so --frozen-lockfile stays valid; + # --ignore-scripts skips host lifecycle scripts no gate loads. + git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \ + | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \ + | tar -C "$repo_root" --null --files-from=- -cf - \ + | tar -C "$scratch/tree" -xf - + cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF' + +nodeLinker: hoisted +supportedArchitectures: + os: [current, win32] + cpu: [current, x64] +EOF + # The hoisted linker — used only by this lane — has an upstream rename + # race (pnpm/pnpm#12880): parallel linkers staging a nested package copy + # (observed on the tree's nested esbuild versions) rename their _tmp_* + # directory onto a path another racer already claimed, and the loser + # exits ERR_PNPM_ENOENT although an identical re-install succeeds. + # Exactly that signature earns up to two retries on a clean tree — the + # snapshot contains no node_modules, so wiping them restores the + # pre-install state; any other failure, or the race still standing after + # the final attempt, fails loud with the log tail. + local attempt + for attempt in 1 2 3; do + (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ + && return 0 + grep -q 'ERR_PNPM_ENOENT.*rename.*_tmp_' "$scratch/logs/install.log" || break + (( attempt < 3 )) || break + echo "wine-windows-gates: pnpm hoisted-linker rename race (pnpm/pnpm#12880) on install attempt $attempt; retrying on a clean tree" >&2 + find "$scratch/tree" -name node_modules -type d -prune -exec rm -rf {} + + done + tail -40 "$scratch/logs/install.log" >&2 + return 1 +} + +mkdir "$scratch/tree" +start=$SECONDS +provision_node & node_pid=$! +boot_wine & wine_pid=$! +snapshot_and_install & install_pid=$! +# Wait for EVERY child before judging any: a bare `wait` under set -e would +# exit on the first failure and let the EXIT trap delete $scratch while the +# other children still run inside it. Named statuses also make the report +# point at the root cause instead of a downstream symptom. +node_status=0; wait "$node_pid" || node_status=$? +wine_status=0; wait "$wine_pid" || wine_status=$? +install_status=0; wait "$install_pid" || install_status=$? +provision_failed=0 +report_provision() { + if (( $2 != 0 )); then + echo "wine-windows-gates: FAILED $1 (exit $2)" >&2 + provision_failed=$2 + fi +} +report_provision 'Windows Node provisioning' "$node_status" +report_provision 'wineboot' "$wine_status" +report_provision 'workspace snapshot + pnpm install' "$install_status" +if (( provision_failed != 0 )); then exit "$provision_failed"; fi +node_win="$(cat "$scratch/node-win-path")" +echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))" + +# ---- resolve entrypoints, lay the vue link, smoke ------------------------ +# Node under Wine cannot attach stdio to pipes the caller owns (Socket open +# EBADF at bootstrap), so every invocation routes stdio through a file. +wine_node() { + local log="$1" + shift + local status=0 + "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$? + return "$status" +} + +cd "$scratch/tree" +tsc_js='node_modules/typescript/bin/tsc' +tsdown_js='node_modules/tsdown/dist/run.mjs' +vitepress_js='node_modules/vitepress/bin/vitepress.js' +[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js' +for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do + [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; } +done +# VitePress links vue into the site's node_modules at build time; Wine cannot +# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones. +if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then + mkdir -p website/node_modules + ln -s ../../node_modules/vue website/node_modules/vue +fi + +wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" +cat "$scratch/logs/smoke.log" +grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } + +# ---- the two blocking surfaces, concurrently ------------------------------ +# The build preserves the face order from package.json: compile and bundle the +# Host face before compiling and bundling the Client face. +# Both statuses are captured so one failure cannot hide the other's result. +build_gate() { + wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? + wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? + wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client +} +site_gate() { + cd website + wine_node "$scratch/logs/site.log" "../$vitepress_js" build . +} + +start=$SECONDS +build_gate & build_pid=$! +site_gate & site_pid=$! +build_status=0 +wait "$build_pid" || build_status=$? +site_status=0 +wait "$site_pid" || site_status=$? +elapsed=$((SECONDS - start)) + +report() { + local label="$1" status="$2" + shift 2 + if (( status == 0 )); then + echo "wine-windows-gates: PASS $label (${elapsed}s window)" + else + echo "== FAILED $label (exit $status) ==" >&2 + for log in "$@"; do tail -n 200 "$log" >&2 || true; done + fi +} +report 'build (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \ + "$scratch/logs/host-tsc.log" \ + "$scratch/logs/host-tsdown.log" \ + "$scratch/logs/client-tsc.log" \ + "$scratch/logs/client-tsdown.log" +report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" +if (( build_status != 0 )); then exit "$build_status"; fi +exit "$site_status" From 61d5cb9e41a38ed770e503129f359859ae91d83f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 10:45:15 +0800 Subject: [PATCH 42/67] fix(apiproxy): serve history events and projections from one log position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two defects in the previous commit's ordering fix. @pku-xht: `historyStateFor` copied the attached session's events, the handler then awaited `presenterScopeFor`, and only then read the projection baseline off the still-live Session. An append during that await served events cut at N beside a baseline folded to N+1 — one response describing two moments. The same restructure had also moved the baseline read outside the `try`, so a failing snapshot escaped the structured `internal` error. Both awaits now happen before the cut: `historySourceFor` resolves which session serves the read, `presenterScopeFor` ensures the recorded composition, and `historyCutOf` then reads events and baseline adjacently with nothing between them. The whole sequence is back inside the try. The invariant judged any scoped assembly with a chain of one as an unjoined agent, which rejects a legitimate assembly in a standing preset key (that key has no parent of its own). It now gates on `context.agent` — a scope-only read is not an agent and is out of range by construction rather than by a premise about who else calls `assemble` — and asks the roster's own `composedPreset()` instead of introspecting chain length. The advisory warning uses the same relation. Also from review: the `2026-08-05-per-agent-tool-presentation` note still described `presentAs` as per-agent, which standing mounts made false and this branch's own rewording contradicts; the duplicated "process-wide unit table" argument collapses to the Agent Note with pointers from the five copies; a dead `.sort()` before `arrayContaining`; and change-history narration in the tool-cordis README. --- ...8-05-per-agent-tool-presentation.i18n.yaml | 4 +- .../2026-08-05-per-agent-tool-presentation.md | 6 +- ...26-08-05-per-agent-tool-presentation.zh.md | 6 +- apps/cli/tests/web-agent-presets.e2e.ts | 4 +- packages/acp/acp/src/index.ts | 8 +- packages/bundle/headless/src/index.ts | 9 +- packages/bundle/web-app/cordis.patch.yml | 15 +-- packages/host/apiproxy/src/api-proxy.ts | 111 +++++++++++------- packages/preset/agent-presets/src/index.ts | 29 ++--- .../preset/agent-presets/src/invariant.ts | 37 +++--- .../agent-presets/tests/invariant.spec.ts | 22 ++-- packages/scaffold/server/src/server.ts | 10 +- .../tool-cordis/README.i18n.yaml | 4 +- .../self-modification/tool-cordis/README.md | 2 +- .../tool-cordis/README.zh.md | 2 +- 15 files changed, 146 insertions(+), 123 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml index 24b04ea72f..92610dde43 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md -2026-08-05-per-agent-tool-presentation.md: 348f7ab0a26e9b39057dbac885304e0d52e0b1fb -2026-08-05-per-agent-tool-presentation.zh.md: 4920ee6eb061d44934bfc9f5176e244f5aac8553 +2026-08-05-per-agent-tool-presentation.md: adb93b51c73d341c153b8fcafe2a08f0a5598478 +2026-08-05-per-agent-tool-presentation.zh.md: fa83bd4daec9d8e6e9e43295bb81af97782b1fdf diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md index 348f7ab0a2..adb93b51c7 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md @@ -12,16 +12,16 @@ The naive reading of "move tools down to the agent plane" does not work. `ctx.to ## Decision -Split the registry from its projection. The registry stays host-plane; the **presentation** becomes per-agent state inside it, alongside the per-agent restrictions and guards that already live there. +Split the registry from its projection. The registry stays host-plane; the **presentation** becomes scope state inside it, alongside the scoped restrictions and guards that already live there. -`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the agent that declared it. `modeFor(scope)` resolves that cell against the config `mode`, which becomes the default for agents declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. +`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `code` preset carries the `tool-mode` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. Two consequences fell out and are load-bearing: - **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need. - **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. -The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per agent by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. +The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-mode`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act. diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md index 4920ee6eb0..fa83bd4dae 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md @@ -12,16 +12,16 @@ agent preset 已经能按会话组装一个 agent 的工具,却管不了这些 ## Decision -把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。 +把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 scope 的状态,与已经住在那里的作用域限制和守卫并列。 -`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 +`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`code` preset 携带 `tool-mode` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 有两个随之而来的结果,且都是承重的: - **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。 - **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 -SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 agent 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 +SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 35e945cba5..1b08bc20df 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -159,7 +159,9 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(Object.keys(projections.snapshot(handle.agent.session).values).sort()) + // A subset assertion: `tasks`, `goal`, and the rest register into the + // same process-wide table, and this is about the meter's three units. + expect(Object.keys(projections.snapshot(handle.agent.session).values)) .toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage'])) } finally { await handle.dispose() diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index d5677c75ba..d595c69e69 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -253,11 +253,9 @@ export function apply(ctx: Context, config: AcpConfig): void { validateSessionParams(params) const sessionId = SessionId(randomUUID()) // No preset composition: the ACP bundle keeps the model-facing rows in - // the host plane, so this agent reads them from the global registry - // layer. A deployment that configures `dsh-agent-presets` has to join - // one here first — registration visibility inherits only along the - // `dsh-scope` parent chain, and an agent created without that link - // reads an empty global layer and reaches the model with no tools. + // the host plane, so this agent reads them from the global layer. A + // deployment that configures a roster has to join one here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const handle = await agents.create({ sessionId, meta: { cwd: params.cwd }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 162e2c0abb..d818e4bccd 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -107,12 +107,9 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { const selection = defaultModel.currentSelection() // This bundle composes no preset roster, so the model-facing rows sit in the - // host plane and the agent reads them from the global registry layer. A - // deployment that DOES configure `dsh-agent-presets` has to join one here - // first: registration visibility inherits only along the `dsh-scope` parent - // chain, and `AgentPresets.mount()` is the only thing that links an agent to - // it, so an agent created without that link reads an empty global layer and - // reaches the model with no tools at all. + // host plane and the agent reads them from the global layer. A deployment + // that DOES configure one has to join it here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const { agent } = await agents.create({ sessionId: SessionId(`session-${randomUUID()}`), meta: { cwd: process.cwd() }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index bf673df28a..6c564a8560 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -301,16 +301,11 @@ disabled: true # The token METER stays on the host plane; only the compaction backend that -# reads it moves. It takes no configuration at all, keys every fold by Session, -# and registers no tool or prompt section, so one host instance serves every -# preset exactly as before. It also owns the `tokenUsage`/`contextPressure`/ -# `contextBreakdown` projection units, and the projection registry is -# process-wide rather than scope-layered: registered from inside a preset, the -# browser's context meter would appear for a session whose own composition -# mounts no meter as soon as some OTHER session mounted one, and disappear -# entirely in a process that only ever ran `minimal`. That is the same criterion -# `tasks` and `goals` are read by — a Service the host and every preset can see -# belongs to the plane both can see. +# reads it moves. It owns the context-meter projection units, and that table is +# process-wide, so preset ownership would make the meter a function of which +# presets happen to be mounted rather than a per-session fact. Same criterion as +# `tasks` and `goals`; the reasoning has one home in +# `.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md`. - id: compact-basic disabled: true diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 34abe41f87..d2d00410ea 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -686,6 +686,16 @@ function historyPage( * registry). An absent registry means the deployment has no projection seam: * the whole block is absent and clients treat every key as capability-absent. */ +/** + * Which session a transcript read is served from. An attached session is the + * live object and keeps appending, so its events and projection baseline are + * read together in one synchronous step; a detached one is already a frozen + * inspection. + */ +type HistorySource = + | { readonly kind: 'attached'; readonly session: Session } + | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] } + function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined { const registry = ctx.get('sessionProjections') if (registry === undefined) return undefined @@ -1330,39 +1340,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** - * Read one transcript cut without acquiring an Agent owner, plus a DEFERRED - * read of the projection baseline. - * - * The baseline is a thunk rather than a value because the unit table is - * process-wide while the units themselves are registered by preset rows: a - * key like `todos` exists only once its preset's standing mount is composed. - * The caller ensures that mount through {@link presenterScopeFor} — which - * needs the header this function returns — so reading the snapshot eagerly - * would serve a first cold read a page missing every preset-owned key, and - * every later read a complete one. + * Resolve which session one transcript read is served from, without + * acquiring an Agent owner. This is the read's only asynchronous step + * besides ensuring the composition; {@link historyCutOf} takes the cut. + * @param sessionId - the transcript being read. + * @returns the attached session, or the inspected detached header and events. + * @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity. */ - async function historyStateFor( - sessionId: SessionId, - includeProjections: boolean, - ): Promise<{ - header: SessionHeader - events: SessionEvent[] - readProjections: () => SessionProjectionsBlock | undefined - }> { + async function historySourceFor(sessionId: SessionId): Promise { const attached = ctx.sessions.get(sessionId) - if (attached !== undefined) { - return { - header: attached.header, - events: [...attached.events], - readProjections: () => includeProjections ? projectionsFor(ctx, attached) : undefined, - } - } + if (attached !== undefined) return { kind: 'attached', session: attached } const inspected = await inspectServable(sessionId) - return { - header: inspected.meta, - events: inspected.events, - readProjections: () => includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined, + return { kind: 'detached', header: inspected.meta, events: inspected.events } + } + + /** + * The header and events {@link presenterScopeFor} reads to decide which + * composition a transcript ran under. + * @param source - the live or detached session this read is served from. + * @returns that session's creation header and its events. + */ + function sourceSession(source: HistorySource): PresetBearingSession { + if (source.kind === 'detached') return { header: source.header, events: source.events } + return { header: source.session.header, events: source.session.events } + } + + /** + * One transcript cut: the events and the projection baseline that describe + * the SAME log position. + * + * Synchronous, and the two reads sit next to each other, because an attached + * session keeps appending: an `await` between them would serve events cut at + * N beside a baseline folded to N+1, which is one response describing two + * moments. The caller does its awaiting before this call. + * @param source - the live or detached session this read is served from. + * @param includeProjections - whether the caller asked for the baseline (a tail page does). + * @returns the events and, when asked, the baseline for that same position. + */ + function historyCutOf( + source: HistorySource, + includeProjections: boolean, + ): { events: SessionEvent[]; projections?: SessionProjectionsBlock } { + if (source.kind === 'detached') { + const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined + return { events: source.events, ...projections === undefined ? {} : { projections } } } + const events = [...source.session.events] + const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined + return { events, ...projections === undefined ? {} : { projections } } } /** @@ -2022,9 +2047,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: Awaited> try { - state = await historyStateFor(sessionId, beforeSeq === undefined) + const source = await historySourceFor(sessionId) + // Both awaits happen BEFORE the cut. Ensuring the recorded + // composition's standing mount is what registers its projection + // units, so a first cold read would otherwise serve a baseline + // missing every preset-owned key; and an attached session keeps + // appending, so awaiting between the two reads would pair events cut + // at N with a baseline folded to N+1. + const scope = await presenterScopeFor(sessionId, sourceSession(source)) + const cut = historyCutOf(source, beforeSeq === undefined) + const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope) + return ok(request, { + events: page.events, + hasMore: page.hasMore, + ...cut.projections === undefined ? {} : { projections: cut.projections }, + }) } catch (error: unknown) { if (error instanceof SessionNotFound) { return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) @@ -2035,17 +2073,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - // The scope resolves first: ensuring the recorded composition's - // standing mount is what registers its projection units, so the - // baseline below has to be read after it, not beside it. - const scope = await presenterScopeFor(sessionId, state) - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, scope) - const projections = state.readProjections() - return ok(request, { - events: page.events, - hasMore: page.hasMore, - ...projections === undefined ? {} : { projections }, - }) }, async models(request) { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index b4fcf4452e..bddab50676 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -24,7 +24,7 @@ import { stat } from 'node:fs/promises' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { bindScopeParent, createScope, scopeChainOf, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' @@ -133,24 +133,21 @@ export class AgentPresets extends Service { }, 'agentPresets.settings()') }) - // An agent joins a preset by having its scope key parented to a standing - // mount, and `mount`/`composeFrom` are the only things in the runtime that - // install that link. An agent that never joined keeps a chain of length - // one, so its `tools`, `system-prompt`, and `skill` views resolve against - // the EMPTY global layer and the model simply has nothing — no error, no - // empty catalog to notice, just an agent that cannot act. + // Advisory, not fatal: a synchronous `agent/created` listener that throws + // VETOES publication, and this service must not, because composing an agent + // outside the roster is legal — `recompose` binds exactly such a bare agent + // below, and the ACP, SDK-server, and headless entry points all create one. + // The invariant companion is the check that fails loud, at assembly. Why an + // unjoined agent matters at all has one home: the [Agent + // Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md). // - // Advisory rather than fatal, and deliberately not the same observation the - // invariant companion makes. A synchronous `agent/created` listener that - // throws VETOES publication, and this service must not: composing an agent - // outside the roster is legal (`recompose` documents the bare agent it then - // binds, and entry points that predate presets still create one), so - // vetoing would turn a capability gap into an outage. The companion fails - // loud instead, at the later point where the empty world reaches a model. + // Known false positive: a session created bare and bound later by + // `recompose` is warned about once, before its first bind. No shipped flow + // does that today — the Web surface mounts in `setup` and children join + // through `composeFrom` before publication. ctx.on('agent/created', ({ agent }) => { if (this.config.roots.length === 0) return - const key = scopeOf(agent.ctx) - if (key !== undefined && scopeChainOf(key).length > 1) return + if (this.composedPreset(agent.ctx) !== undefined) return ctx.logger.warn( `agent "${agent.id}" was published without joining an agent preset; ` + 'its tools, prompt sections, and skill catalog resolve against the empty global layer ' diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index 686c2c1960..e9240a0b2d 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -5,9 +5,10 @@ import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { scopeChainOf } from '@deepseek-ai/dsh-scope' -// Type-only: resolves the `system-prompt/assemble` waterfall this companion joins. +// Type-only: resolves the `system-prompt/assemble` waterfall this companion +// joins, and the `agent` field `dsh-agent` merges into its context. import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-agent' // Imported through the package name, not `./mount.ts`: a module shared between // the two build entry points becomes a third chunk that the published `files` // list does not carry, which `verify-built-package-invariants` rejects. @@ -42,26 +43,28 @@ const install: InvariantInstaller = (ctx, fail) => { } }, { global: true }) - // The join is a scope-parent link, and `AgentPresets.mount()` is the only - // thing in the runtime that installs one. An agent minted without it keeps a - // chain of length one, so its `tools`, `system-prompt`, and `skill` views - // fall back to the empty global layer and the model receives nothing. + // An agent that joined no preset resolves `tools`, `system-prompt`, and + // `skill` against the empty global layer, so the model receives nothing. + // `composedPreset()` is the roster's own answer to "did this agent join", + // read from the live scope chain — see the [Agent + // Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md) + // for why the warning beside it is advisory while this one fails. // - // Checked at ASSEMBLY, not at publication: an unjoined agent is legal until - // it addresses a model — `recompose` binds a bare agent as its first link, - // and that agent is unjoined for its whole life up to the switch. Assembling - // a prompt is the point where the empty world stops being a state and - // becomes what the model sees, and it is the only caller that supplies an - // agent scope, so a host assembly (no scope) and a standing mount are both - // correctly out of range. + // Two conditions, each load-bearing. `context.agent` is what makes this an + // AGENT assembly: a scope-only assembly — a cold read resolving presenters + // in a standing key, a diagnostic — is not an agent and must not be judged + // on whether it joined anything. And assembly rather than publication is the + // moment that matters, because an unjoined agent is legal until it addresses + // a model: `recompose` binds a bare agent as its first link, and that agent + // is unjoined for its whole life up to the switch. ctx.on('system-prompt/assemble', (_assembly, context, next) => { const presets = ctx.get('agentPresets') - const scope = context.scope + const agent = context.agent if (presets !== undefined && presets.config.roots.length > 0 - && scope !== undefined && scopeChainOf(scope).length === 1) { + && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) { fail( - 'an agent addressed a model without joining any agent preset while a roster is composed; ' - + 'its tools, prompt sections, and skill catalog resolve against the empty global layer', + `agent "${agent.id}" addressed a model without joining any agent preset while a roster is ` + + 'composed; its tools, prompt sections, and skill catalog resolve against the empty global layer', ) } return next() diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 951cb56b90..709ee5ba00 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -7,7 +7,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantService from '@deepseek-ai/dsh-invariants' import { describe, expect, it } from 'vitest' @@ -87,25 +87,31 @@ describe('agent-presets invariants', () => { it('rejects an agent that addresses a model without joining any preset', async () => { const ctx = await harness() - // The delegation shape: an agent composed outside the roster keeps a - // scope chain of length one, so every registry view it reads is the empty - // global layer. Publication alone stays legal — `recompose` binds exactly - // such an agent — so nothing fires until that empty world reaches a prompt. + // The delegation shape: an agent composed outside the roster joined no + // standing mount, so every registry view it reads is the empty global + // layer. Publication alone stays legal — `recompose` binds exactly such an + // agent — so nothing fires until that empty world reaches a prompt. const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') }) - await expect(ctx.systemPrompt.assemble({ scope: handle.agent })) + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))) .rejects.toThrow(/without joining any agent preset/) }) - it('admits a joined agent and a host assembly that names no scope', async () => { + it('admits a joined agent, a scopeless read, and a standing-key read', async () => { const ctx = await harness() const handle = await ctx.agents.create({ sessionId: SessionId('inv-joined'), setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), }) - await expect(ctx.systemPrompt.assemble({ scope: handle.agent })).resolves.toBeDefined() + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined() // A scopeless assembly belongs to no agent, so it cannot be an unjoined one. await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined() + // Neither can a scope that is not an agent at all: a standing preset key + // has no parent of its own, so a chain-length rule would reject the cold + // read that resolves presenters in it. `context.agent` is what keeps this + // check to agent assemblies. + const standing = await ctx.agentPresets.standingKeyFor('standard') + await expect(ctx.systemPrompt.assemble({ scope: standing })).resolves.toBeDefined() }) }) diff --git a/packages/scaffold/server/src/server.ts b/packages/scaffold/server/src/server.ts index 2f149c160b..6c941509fa 100644 --- a/packages/scaffold/server/src/server.ts +++ b/packages/scaffold/server/src/server.ts @@ -216,12 +216,10 @@ export class HarnessSdkServer { } private async createSession(sessionId: string): Promise { - // No preset composition: the SDK server's compositions keep the - // model-facing rows in the host plane, so this agent reads them from the - // global registry layer. A deployment that configures `dsh-agent-presets` - // has to join one here first — registration visibility inherits only along - // the `dsh-scope` parent chain, and an agent created without that link - // reads an empty global layer and reaches the model with no tools. + // No preset composition: this server's compositions keep the model-facing + // rows in the host plane, so this agent reads them from the global layer. A + // deployment that configures a roster has to join one here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, diff --git a/packages/self-modification/tool-cordis/README.i18n.yaml b/packages/self-modification/tool-cordis/README.i18n.yaml index e95201156a..c342e66463 100644 --- a/packages/self-modification/tool-cordis/README.i18n.yaml +++ b/packages/self-modification/tool-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/tool-cordis/README.md -README.md: 946d96c6f0968be3ecb162c52436f10985836b77 -README.zh.md: 8f94783f96e3652e27bdddd9cc7f54081f8c04bf +README.md: be629ca9be5bff1e6f658f05476e8f2880804e44 +README.zh.md: 45f3536d3460106c45051093d097729a366eca1a diff --git a/packages/self-modification/tool-cordis/README.md b/packages/self-modification/tool-cordis/README.md index 946d96c6f0..be629ca9be 100644 --- a/packages/self-modification/tool-cordis/README.md +++ b/packages/self-modification/tool-cordis/README.md @@ -87,4 +87,4 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). - **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths. - **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. -- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. That matched the host-plane row this package started as (one per process); it becomes observable once several sessions run the same preset concurrently. Per-session temporary plugins would need the group and table keyed by the calling agent. +- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. Several sessions running one preset concurrently is where that becomes observable. Per-session temporary plugins would need the group and table keyed by the calling agent. diff --git a/packages/self-modification/tool-cordis/README.zh.md b/packages/self-modification/tool-cordis/README.zh.md index 8f94783f96..45f3536d34 100644 --- a/packages/self-modification/tool-cordis/README.zh.md +++ b/packages/self-modification/tool-cordis/README.zh.md @@ -87,4 +87,4 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 - **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。 - **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 -- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。这与本包最初作为宿主平面行的语义一致(每进程一份);只有当多个会话并发运行同一 preset 时才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。 +- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。多个会话并发运行同一 preset 时这一点才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。 From 76cf6cbd0b8d8959c7aefc7d9789d53a74a2ce39 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 11 Aug 2026 11:50:39 +0800 Subject: [PATCH 43/67] feat(subagent): make final reports the continuable return contract A continuable child could finish without giving its parent a usable result: the report tool was described as optional and its default delivery stayed quiet. Install the child-scoped report obligation (tool description plus the tool:report prompt section), default reportDelivery to wakeup, bind shipped subagent_fork delegations to one-shot so fork keeps its prefix reuse, and extend the ACP snapshot harness to pin child system prompts alongside child tool schemas. --- ...8-10-fork-children-stay-one-shot.i18n.yaml | 6 + .../2026-08-10-fork-children-stay-one-shot.md | 49 ++++++ ...26-08-10-fork-children-stay-one-shot.zh.md | 49 ++++++ ...continuable-subagent-report-tool.i18n.yaml | 4 +- ...-07-30-continuable-subagent-report-tool.md | 20 +-- ...-30-continuable-subagent-report-tool.zh.md | 20 +-- ...tinuable-child-report-obligation.i18n.yaml | 6 + ...-06-continuable-child-report-obligation.md | 60 +++++++ ...-continuable-child-report-obligation.zh.md | 60 +++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 9 +- docs/config-catalog.zh.md | 9 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 12 +- docs/tool-catalog.zh.md | 12 +- examples/acp-agent/cordis.yml | 6 +- examples/acp-agent/tests/acp.snapshot.ts | 8 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 6 +- .../both-mode-turn/tool-schemas.expected.json | 6 +- .../code-mode-turn/system-prompt.expected.md | 4 +- .../lsp-definition/tool-schemas.expected.json | 6 +- .../tool-schemas.expected.json | 6 +- .../tool-schemas.expected.json | 6 +- .../pty-tools/tool-schemas.expected.json | 6 +- .../tool-schemas.expected.json | 6 +- .../tool-schemas.expected.json | 6 +- .../system-prompt.1.expected.md | 24 +++ .../tool-schemas.1.expected.json | 10 +- .../system-prompt.1.expected.md | 24 +++ .../tool-schemas.1.expected.json | 10 +- .../session.1.jsonl | 2 +- .../session.2.jsonl | 2 +- .../system-prompt.1.expected.md | 24 +++ .../tool-schemas.1.expected.json | 10 +- .../snapshots/subagent-mixed/session.1.jsonl | 2 +- .../snapshots/subagent-mixed/session.2.jsonl | 2 +- .../snapshots/subagent-multi/session.1.jsonl | 2 +- .../snapshots/subagent-multi/session.2.jsonl | 2 +- .../snapshots/subagent-report/input.json | 7 + .../snapshots/subagent-report/session.1.jsonl | 16 +- .../snapshots/subagent-report/session.jsonl | 54 ++++--- .../subagent-report/stdout.expected.jsonl | 1 + .../system-prompt.1.expected.md | 24 +++ .../tool-schemas.1.expected.json | 10 +- .../text-turn/tool-schemas.expected.json | 6 +- .../web-fetch/tool-schemas.expected.json | 6 +- examples/headless-agent/cordis.yml | 6 +- .../advanced-toolchain/session.1.jsonl | 10 +- .../advanced-toolchain/session.2.jsonl | 10 +- .../advanced-toolchain/session.jsonl | 28 ++-- .../compaction-recovery/session.jsonl | 14 +- .../tests/snapshots/pty-tools/session.jsonl | 34 ++-- packages/bundle/base/cordis.patch.yml | 6 +- .../subagent/subagent-fork/README.i18n.yaml | 4 +- packages/subagent/subagent-fork/README.md | 3 +- packages/subagent/subagent-fork/README.zh.md | 3 +- packages/subagent/subagent-fork/src/index.ts | 6 + .../tool-subagent-report/README.i18n.yaml | 4 +- .../subagent/tool-subagent-report/README.md | 20 +-- .../tool-subagent-report/README.zh.md | 20 +-- .../tool-subagent-report/package.json | 2 + .../tool-subagent-report/src/index.ts | 147 ++++++++++++------ .../tests/tool-subagent-report.spec.ts | 126 ++++++++++++++- .../tool-subagent-report/tsconfig.json | 3 + .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 + packages/support/acp-snapshot/README.zh.md | 2 + packages/support/acp-snapshot/src/suite.ts | 87 +++++++++-- .../fixtures/suite/plain-turn/behavior.json | 2 +- .../plain-turn/system-prompt.1.expected.md | 3 + .../support/acp-snapshot/tests/suite.spec.ts | 33 ++++ pnpm-lock.yaml | 3 + scripts/gen-tool-catalog.ts | 14 +- 79 files changed, 916 insertions(+), 326 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/system-prompt.1.expected.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/system-prompt.1.expected.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-list-agents/system-prompt.1.expected.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-report/system-prompt.1.expected.md create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/system-prompt.1.expected.md 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 new file mode 100644 index 0000000000..ef424471d8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md +2026-08-10-fork-children-stay-one-shot.md: 030bed3c1b516a54afd5f97b00000ed7ceaf64d8 +2026-08-10-fork-children-stay-one-shot.zh.md: 4b033a70c3674315c491e85be67a1d357aeac51b 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 new file mode 100644 index 0000000000..030bed3c1b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md @@ -0,0 +1,49 @@ +# Agent Note: Forked children stay one-shot + +Status: implemented + +English | [中文](2026-08-10-fork-children-stay-one-shot.zh.md) + +## Problem + +Fork's only difference from spawn is that the child Session is seeded with the parent's completed-turn prefix ([subagent-fork](../../../../packages/subagent/subagent-fork/README.md)). That seed costs real tokens — the inherited history is re-sent in every child request — and its one concrete payoff is provider-side prefix reuse: under the same provider and model, a child request whose leading bytes are identical to the parent's re-prefills none of the shared span. Anything a child scope adds *ahead* of the inherited history spends that payoff, because reuse stops at the first differing byte. + +The child-scoped `report` return channel is now the largest such addition, and since [the report obligation](../feature/2026-08-06-continuable-child-report-obligation.md) it is two deltas rather than one: the `report` tool schema and the `tool:report` system-prompt section. Both live in the request head — the system block and the tool block precede every message — so a continuable forked child invalidates reuse before the first inherited turn and re-prefills the whole transcript it was forked to reuse. That composition pays fork's duplication cost and collects none of its benefit, while the parent still holds a reusable prefix the child could have shared. + +## Decision + +Every shipped composition binds the fork delegation tool to `backgroundMode: one-shot`: [the base bundle](../../../../packages/bundle/base/cordis.patch.yml), [the ACP example](../../../../examples/acp-agent/cordis.yml), and [the headless example](../../../../examples/headless-agent/cordis.yml). The base bundle leaves `run_in_background` available, because it mounts a task service; the two examples set `enableRunInBackground: false`, because they mount none and a one-shot background start would otherwise fail at call time on a missing `tasks` service. + +One-shot children — foreground and background alike — are created through `SubagentService.start()`, which never enters the continuable activation-setup registry, so neither `report` nor its prompt section is installed. A forked one-shot child's system prompt and tool schemas therefore equal its parent's, apart from the `persona` and `toolFilter` deltas a deployment opts into per delegation tool. + +`spawn` keeps `backgroundMode: continuable`. Continuable children and the report obligation ship unchanged for the provider whose child starts with no inherited prefix to protect, so this decision costs the report channel nothing. + +### The restriction is composition, not code + +`ForkProvider.prepareContinuable` stays implemented and `ctx.subagents.startContinuable()` still accepts `fork`; only the shipped `cordis.yml` rows changed. `tool-subagent` knows both the provider's `inheritsParentContext` and its own `backgroundMode` at mount, so a load-time rejection of the pair was available and is deliberately not added: the pair is not wrong in general. It is wrong only while a child-scope delta precedes inherited history, and the package that creates that delta — [`dsh-tool-subagent-report`](../../../../packages/subagent/tool-subagent-report/README.md) — is separately installable and, by its own design, invisible to `tool-subagent`. A deployment that omits the report package can run continuable forked children with the prefix intact. Encoding one roster's consequence as a delegation-tool invariant would make the tool assert something it cannot observe. + +The reintroduction condition is recorded as a `TODO(fork-continuable-prefix-reuse)` marker on `prepareContinuable` itself, the one method the shipped compositions do not call, and tracked as issue #2124: continuable fork reopens when a child's system prompt and tool schemas can match its parent's byte for byte. + +## Alternatives considered + +**Reject `inheritsParentContext` + `continuable` at mount.** A loud load-time failure would prevent silent reintroduction, which is what the configuration change cannot do. Rejected because the delegation tool cannot see the report package and the combination is legitimate without it; the invariant would be false for a deployment that never installs a child-scope delta, and `tool-subagent` would be asserting a fact owned by the roster. + +**Stop mounting the fork provider at all.** This was the broader form of the restriction. Rejected because foreground fork *is* the prefix-reusing case and is untouched by the report channel, so a full ban gives up the capability without buying anything the one-shot binding does not already buy — and would leave no shipped composition exercising session seeding. + +**Ship continuable forked children and accept the loss.** Rejected because the loss is total rather than marginal: reuse breaks ahead of the inherited history, so the child pays full prefill on a transcript it duplicated for the sole purpose of not paying it. A deployment that wants a long-lived child with no inherited context already has `spawn`. + +**Make `report` visible to every Agent.** A global registration would restore byte-identical prefixes by giving parent and child the same schema and section. Rejected because roots, one-shot children, remote children, and agentless callers would advertise a tool with no derivable recipient, and execution-time rejection would make schema visibility disagree with authority — the scope-local decision the [report tool Agent Note](../feature/2026-07-30-continuable-subagent-report-tool.md) already settled. + +**Install the child-scope deltas after the inherited history.** Rejected as unrepresentable: the system prompt and the tool schemas are request-head structures in every provider's wire format, so no ordering within them can place a child-only addition behind the message list. + +## Consequences + +- No shipped composition creates a continuable forked child; `subagent_fork` returns a result to its caller's turn, and `send_message` addresses only spawned children. +- A forked child's request prefix stays byte-identical to its parent's unless the deployment configures `persona` or `toolFilter` on the fork delegation tool, so the token cost of seeding buys provider-side reuse again. +- The fork provider's continuable path has no production caller and no assembled-composition coverage. It keeps its package-level tests, and the seam still accepts it, so a bundle or `--patch` overlay can reintroduce it with no code change and no warning. +- `subagent_fork`'s model-visible schema changes: the continuable background wording is replaced by the one-shot task wording in the base bundle, and disappears entirely from the two examples. The affected keyless snapshot tool-schema sidecars are re-recorded in the same change. +- The report obligation's reach narrows to spawned children in shipped deployments. Its default `wakeup` scheduling, authority model, and coverage are unchanged. + +### Accepted risks + +The constraint lives in three configuration files and a code comment, not in a gate. A future bundle row or profile patch can set `backgroundMode: continuable` on a fork tool and silently reintroduce the prefix loss; nothing fails loud. That is the accepted cost of not encoding one roster's consequence into `tool-subagent`. 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 new file mode 100644 index 0000000000..4b033a70c3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md @@ -0,0 +1,49 @@ +# Agent Note: fork 出的 child 保持 one-shot + +Status: implemented + +[English](2026-08-10-fork-children-stay-one-shot.md) | 中文 + +## 问题 + +fork 与 spawn 的唯一区别是 child 的 Session 会以 parent 已完成轮次的前缀作为初始内容(见 [subagent-fork](../../../../packages/subagent/subagent-fork/README.md))。这份初始内容有实打实的 token 成本——继承的历史会在 child 的每次请求中重新发送——而它唯一确定的回报是提供方侧的前缀复用:在提供方与模型相同的前提下,起始字节与 parent 逐字节相同的 child 请求,无需为这段共享区间重新预填充。任何由 child 作用域添加在继承历史*之前*的内容都会消耗掉这份回报,因为复用在第一个不同字节处即告停止。 + +作用域局部的 `report` 返回通道现在是此类添加中最大的一项,而自[report 义务](../feature/2026-08-06-continuable-child-report-obligation.md)起它是两项而非一项增量:`report` 工具 schema,以及 `tool:report` 系统提示词 section。两者都位于请求头部——系统块与工具块先于所有消息——因此一个可继续的 fork child 会在第一条继承轮次之前就使复用失效,并重新预填充它当初 fork 就是为了复用的整份 transcript(文本记录)。这种组合付出了 fork 的复制成本却收不到它的收益,而 parent 手上仍握着一份 child 本可共享的可复用前缀。 + +## 决策 + +所有随附组合都把 fork 委派工具绑定为 `backgroundMode: one-shot`:[base 组合包](../../../../packages/bundle/base/cordis.patch.yml)、[ACP 示例](../../../../examples/acp-agent/cordis.yml)与[headless 示例](../../../../examples/headless-agent/cordis.yml)。base 组合包保留 `run_in_background`,因为它挂载了 task 服务;两个示例设置 `enableRunInBackground: false`,因为它们都不挂载 task 服务,否则一次 one-shot 后台启动会在调用时因缺少 `tasks` 服务而失败。 + +one-shot child——前台与后台皆然——经由 `SubagentService.start()` 创建,该路径从不进入可继续的 activation setup 注册表,因此 `report` 与它的提示词 section 都不会被安装。于是一个 fork 出的 one-shot child 的系统提示词与工具 schema 与其 parent 相同,只差部署逐个委派工具主动选择的 `persona` 与 `toolFilter` 增量。 + +`spawn` 保持 `backgroundMode: continuable`。对于 child 起步时本就没有继承前缀需要保护的那个提供方,可继续 child 与 report 义务随附行为不变,因此本决策没有让 report 通道付出任何代价。 + +### 该限制在于组合,不在于代码 + +`ForkProvider.prepareContinuable` 仍然实现完好,`ctx.subagents.startContinuable()` 也仍接受 `fork`;改动的只有随附的 `cordis.yml` 行。`tool-subagent` 在挂载时同时知道提供方的 `inheritsParentContext` 与自身的 `backgroundMode`,因此一个加载期拒绝该组合的检查是可行的,而这里刻意不加:该组合并非普遍错误。它只在某个 child 作用域增量位于继承历史之前时才是错的,而产生该增量的包——[`dsh-tool-subagent-report`](../../../../packages/subagent/tool-subagent-report/README.md)——是独立安装的,并且按其自身设计对 `tool-subagent` 不可见。一个不安装 report 包的部署可以在前缀完好的前提下运行可继续的 fork child。把某一份插件清单的后果写成委派工具的不变量,会让该工具断言它无法观察到的事实。 + +重新开放的条件记录为 `prepareContinuable` 方法上的 `TODO(fork-continuable-prefix-reuse)` 标记——随附组合不调用这个方法——并由 issue #2124 跟踪:当 child 的系统提示词与工具 schema 能与其 parent 逐字节一致时,可继续 fork 即可重新开放。 + +## 备选方案 + +**在挂载时拒绝 `inheritsParentContext` 与 `continuable` 的组合。** 一次响亮的加载期失败可以阻止悄然的重新引入,而配置改动做不到这一点。否决的原因是委派工具看不到 report 包,且在没有它时该组合是合法的;对于从不安装任何 child 作用域增量的部署,这个不变量是假的,而 `tool-subagent` 会去断言一件由插件清单拥有的事实。 + +**干脆不挂载 fork 提供方。** 这是该限制更彻底的形式。否决的原因是前台 fork *正是*复用前缀的那种情形,且不受 report 通道影响,因此全面禁用会在不换来任何 one-shot 绑定尚未换来的东西的同时放弃该能力——并且随附组合将没有任何一个演练 session 初始内容。 + +**照常随附可继续的 fork child 并接受这份损失。** 否决的原因是这份损失是全额而非边际的:复用在继承历史之前就已中断,于是 child 为一份自己复制过来、目的恰恰是不必付费的 transcript 付了全额预填充。想要一个没有继承上下文的长期 child 的部署,本来就有 `spawn`。 + +**让 `report` 对每个 Agent 可见。** 全局注册会通过让 parent 与 child 拥有相同的 schema 与 section 来恢复逐字节相同的前缀。否决的原因是根 agent、one-shot child、远端 child 与无 agent 调用方都会宣告一件推导不出收件方的工具,而执行期拒绝会让 schema 可见性与权限彼此矛盾——这正是[report 工具 Agent Note](../feature/2026-07-30-continuable-subagent-report-tool.md)已经定下的作用域局部决策。 + +**把 child 作用域增量安装到继承历史之后。** 否决的原因是它无法表达:在每个提供方的协议格式中,系统提示词与工具 schema 都是请求头部结构,因此它们内部的任何排序都无法把仅属于 child 的添加放到消息列表之后。 + +## 后果 + +- 没有任何随附组合会创建可继续的 fork child;`subagent_fork` 把结果返回给调用方的轮次,而 `send_message` 只寻址 spawn 出的 child。 +- 除非部署在 fork 委派工具上配置了 `persona` 或 `toolFilter`,fork child 的请求前缀与其 parent 逐字节相同,因此初始内容的 token 成本重新换来了提供方侧的复用。 +- fork 提供方的可继续路径没有生产调用方,也没有整体组装层面的覆盖。它保留自己的包内测试,seam 也仍然接受它,因此某个组合包或 `--patch` 覆盖层可以无需改动代码、也不会有任何警告地把它重新引入。 +- `subagent_fork` 面向模型的 schema 发生变化:base 组合包中可继续的后台措辞被 one-shot 的 task 措辞取代,在两个示例中则完全消失。受影响的无密钥快照工具 schema 伴随文件在同一次改动中重新记录。 +- 在随附部署中,report 义务的覆盖范围收窄到 spawn 出的 child。它的 `wakeup` 默认调度、权限模型与覆盖均保持不变。 + +### 已接受的风险 + +该限制存在于三个配置文件与一处代码注释中,而不在门禁里。未来某个组合包行或 profile 补丁可以在 fork 工具上设置 `backgroundMode: continuable`,从而悄然重新引入前缀损失;没有任何东西会失败得很响亮。这就是不把某一份插件清单的后果写入 `tool-subagent` 所接受的代价。 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml index 775794a381..d250012eb9 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md -2026-07-30-continuable-subagent-report-tool.md: 6a607f1da48fdfde36b2d9351f131dd3ef67ba7f -2026-07-30-continuable-subagent-report-tool.zh.md: b4d65fc71695e9fca46f4e2c69c9749745de260a +2026-07-30-continuable-subagent-report-tool.md: d4681d0ea30335a5537525927ecad615e6edd8b8 +2026-07-30-continuable-subagent-report-tool.zh.md: 731225d2a5b788997704fafe9331901ca9b84393 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md index 6a607f1da4..d4681d0ea3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md @@ -12,7 +12,7 @@ Treating every final assistant message as an implicit result would conflate turn ## Decision -Add the independently installed `@deepseek-ai/dsh-tool-subagent-report` package. It contributes an ordinary model-facing `report` tool to each continuable in-process child Activation. A child may call it zero or multiple times in a turn. Success neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. +Add the independently installed `@deepseek-ai/dsh-tool-subagent-report` package. It contributes an ordinary model-facing `report` tool to each continuable in-process child Activation. The mechanism accepts zero or multiple calls in a turn; the child is separately instructed to call it once before finishing ([the report obligation](2026-08-06-continuable-child-report-obligation.md)). Success neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The feature is a collaboration control, not a result-bearing execution wrapper. It adds no Task, `SubagentRun`, result promise, Activation state, delivery queue, or replay path. @@ -22,7 +22,7 @@ The feature is a collaboration control, not a result-bearing execution wrapper. `messageId` is the stable `MessageId` of the user-role message accepted by the parent. It is not an `InboxItemId`: quiet delivery creates no inbox occurrence, while waking delivery creates one occurrence for the same stable message. It is also not a read receipt, parent-log acknowledgement, turn-completion receipt, or persistence flush. -The description states that reporting is explicit, repeatable, direct-parent-only, and non-terminal. It warns that a failed tool result may still follow an accepted send because a later `tools/post-execute` failure can replace the result. Without an idempotency key, stronger wording would encourage duplicate retries after ambiguous failure. +The description states that reporting is required before finishing, repeatable, direct-parent-only, and non-terminal. It warns that a failed tool result may still follow an accepted send because a later `tools/post-execute` failure can replace the result. Without an idempotency key, stronger wording would encourage duplicate retries after ambiguous failure. The tool uses generic rendering with no locations. Its acknowledgement includes `messageId`. Scope-local registration keeps presentation and execution aligned: roots, one-shot children, remote providers, sibling scopes, and agentless execution neither see nor execute `report`. It installs after the child's global `toolFilter`, so a delegation allow-list cannot accidentally remove the structural return channel; deployments that require no return channel omit the package. @@ -36,7 +36,7 @@ Nested reporting crosses exactly one edge. A grandchild reports to its direct ch ### Delivery policy -The package validates `reportDelivery: 'quiet' | 'wakeup'`; the default is `quiet`. +The package validates `reportDelivery: 'quiet' | 'wakeup'`; the default is `wakeup` ([why the default reversed](2026-08-06-continuable-child-report-obligation.md)). Quiet delivery calls `parent.inject()`. It adds model-visible context without starting a parent model request: an idle parent appends before the call returns, while an admitting or running parent stages the report for the next safe log position. It creates no inbox occurrence and therefore no synthetic continuation-manager acceptance record. @@ -56,13 +56,13 @@ The subagent seam adds `registerContinuableSetup(contribution): () => void`, bac The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. Applying a batch returns the Agent setup commit that revalidates provisioning after every setup await and immediately before Agent publication. A throwing or concurrently revoked contribution therefore rejects before either Agent or Session publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. -This seam keeps the continuation manager unaware of tool names. The report package installs only `report`; `@deepseek-ai/dsh-tool-subagent-control` independently installs parent-side `send_message` and `list_agents`. A deployment can install either direction, both, or neither. Providers remain data-only, durable descriptors do not snapshot report availability or delivery mode, and cold resume uses the deployment's current contributions and policy. +This seam keeps the continuation manager unaware of tool names. The report package installs only `report` and its child-scoped guidance section; `@deepseek-ai/dsh-tool-subagent-control` independently installs parent-side `send_message` and `list_agents`. A deployment can install either direction, both, or neither. Providers remain data-only, durable descriptors do not snapshot report availability or delivery mode, and cold resume uses the deployment's current contributions and policy. ### Snapshot coverage -The ACP snapshot harness adds `waitForSubagentTurnEnd`, selecting the Nth harvested child by the same order as `session.N.jsonl`. It waits for a closed child turn containing a request header so a continuable child's earlier descriptor-seed turn cannot satisfy the boundary. This lets the assembled quiet-mode scenario wait for the child-side report without inventing a parent-visible signal. +The ACP snapshot harness adds `waitForSubagentTurnEnd`, selecting the Nth harvested child by the same order as `session.N.jsonl`. It waits for a closed child turn containing a request header so a continuable child's earlier descriptor-seed turn cannot satisfy the boundary. This lets the assembled scenario wait for the child-side report without inventing a parent-visible signal. -The authored snapshot starts a continuable child, executes the real scope-local `report` tool, confirms that the idle parent is not woken, and then submits a later parent prompt that consumes the framed report. It declares child schema pin `1`, so the otherwise non-global `report` schema is checked against `tool-schemas.1.expected.json` while the root keeps the default schema pin. The generated tool catalog separately mints a child scope to include the same scope-local schema. +The authored snapshot starts a continuable child, executes the real scope-local `report` tool, observes the one ordinary parent turn the default waking delivery creates, and then submits a later parent prompt that consumes the framed report. It declares child pins `1`, so the otherwise non-global `report` schema and the child's own prompt are checked against `tool-schemas.1.expected.json` and `system-prompt.1.expected.md` while the root keeps the class pins. The generated tool catalog separately mints a child scope to include the same scope-local schema. ## Alternatives considered @@ -72,7 +72,7 @@ Automatic delivery cannot represent zero reports, progress reports, or several s ### Always wake the parent -Waking on every report creates unsolicited turns and can cascade through nested subagents. Quiet delivery matches background coordination better as the default, while deployments that require immediate action can select wakeup. +Waking on every report creates unsolicited turns and can cascade through nested subagents. Quiet delivery was chosen as the default on the assumption that the parent had another reason to read its context. [The report obligation](2026-08-06-continuable-child-report-obligation.md) supersedes that choice: a parked background coordinator has no such reason, so waking is the default and this paragraph now records why `quiet` still exists. ### Let the child choose the delivery mode @@ -103,16 +103,16 @@ A post-creation revocation check can reject the Activation only after the Agent - A continuable in-process child exposes exactly one scope-local `report` schema only while the report package's contribution is installed; unrelated Agents never expose it. - The tool returns the parent message's stable `MessageId`. Quiet delivery has no `InboxItemId`; waking delivery has a separate inbox occurrence. - Only the exact resident child may report, and only to the exact live direct parent derived from durable lineage. The service has no recipient parameter or offline fallback. -- Quiet delivery is the validated default and never starts a parent request. Wakeup creates exactly one later FIFO turn and never steers an open turn. +- Waking delivery is the validated default: it creates exactly one later FIFO turn and never steers an open turn. Quiet delivery never starts a parent request. - Child cancellation or disposal after parent acceptance does not retract the report. Before acceptance, child disposal, drain, parent loss, or caller cancellation rejects the operation. - Fresh and resumed Activations compose current setup contributions before publication. Grants wait for the next Activation; revocation is immediate for resident children. - Unit coverage pins visibility, allow-list behavior, both delivery modes, stable message and sender identities, nested routing, invalid senders, absent parents, cancellation, drain, revocation races, and the absence of Tasks or implicit final reporting. -- The keyless assembled snapshot proves the real child tool, quiet non-wakeup behavior, durable parent framing, and later parent consumption. +- The keyless assembled snapshot proves the real child tool, the one waking parent turn, durable parent framing, and later parent consumption. ### Accepted risks The acceptance boundary is weaker than durable end-to-end delivery. A crash can leave the result ambiguous, and retries may duplicate reports. -Wakeup mode can amplify model work when nested children report frequently. Deployment ownership and a quiet default limit but do not remove that risk. +Waking delivery can amplify model work when nested children report frequently. Deployment ownership through `reportDelivery` bounds but does not remove that risk. Registry presence is the parent liveness signal. A host-owned parent whose `AgentHandle.dispose()` has started but has not yet unwound its scope can still accept and append a report that it will not act on in this process. Closing that gap requires an Agent-level disposal-start signal rather than subagent-layer inference. diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md index b4d65fc716..731225d2a5 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -新增可独立安装的 `@deepseek-ai/dsh-tool-subagent-report` 包。它会向每个可继续进程内 child Activation 贡献一个普通的面向模型 `report` 工具。child 在一个轮次中可调用零次或多次。调用成功既不会结束该轮次或结算 Activation,也不会阻止 parent 之后继续 follow-up;完成轮次也绝不会自动报告。 +新增可独立安装的 `@deepseek-ai/dsh-tool-subagent-report` 包。它会向每个可继续进程内 child Activation 贡献一个普通的面向模型 `report` 工具。机制本身接受一个轮次中调用零次或多次;child 会另行被要求在结束前调用一次(见[报告义务](2026-08-06-continuable-child-report-obligation.md))。调用成功既不会结束该轮次或结算 Activation,也不会阻止 parent 之后继续 follow-up;完成轮次也绝不会自动报告。 该功能是协作控制,不是承载结果的执行包装层。它不新增 Task、`SubagentRun`、结果 promise、Activation 状态、投递队列或回放路径。 @@ -22,7 +22,7 @@ Status: implemented `messageId` 是 parent 接受的用户角色消息所对应的稳定 `MessageId`。它不是 `InboxItemId`:静默投递不创建 inbox 条目实例,唤醒投递则会为同一条稳定消息创建一个条目实例。它也不是已读回执、parent 日志确认、轮次完成回执或持久化 flush。 -工具描述会明确报告操作是显式、可重复、仅限直接 parent 且不会结束轮次的。它还会警告:发送被接受后,后续 `tools/post-execute` 失败可能替换工具结果,因此工具结果失败时内容仍可能已经送达。没有幂等键时,更强的表述会诱导调用方在结果不明确的失败后重复重试。 +工具描述会明确报告操作在结束前必须执行、可重复、仅限直接 parent 且不会结束轮次。它还会警告:发送被接受后,后续 `tools/post-execute` 失败可能替换工具结果,因此工具结果失败时内容仍可能已经送达。没有幂等键时,更强的表述会诱导调用方在结果不明确的失败后重复重试。 该工具使用不带 location 的通用渲染,其确认中包含 `messageId`。作用域局部注册使呈现与执行保持一致:root、one-shot child、远程提供方、同级作用域和无 agent(智能体)执行既不能看到,也不能执行 `report`。它会在 child 的全局 `toolFilter` 之后安装,因此委派 allow-list 不会意外移除这条结构性返回通道;不需要返回通道的部署不安装该包。 @@ -36,7 +36,7 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以 ### 投递策略 -该包会校验 `reportDelivery: 'quiet' | 'wakeup'`,默认值为 `quiet`。 +该包会校验 `reportDelivery: 'quiet' | 'wakeup'`,默认值为 `wakeup`(见[默认值反转的理由](2026-08-06-continuable-child-report-obligation.md))。 静默投递调用 `parent.inject()`。它会添加模型可见上下文,但不启动 parent 模型请求:若 parent 空闲,则在调用返回前追加消息;若 parent 正在准入或运行,则暂存报告,留到下一个安全日志位置。该模式不创建 inbox 条目实例,因此也不会产生虚构的继续执行管理器接受记录。 @@ -56,13 +56,13 @@ subagent seam 新增 `registerContinuableSetup(contribution): () => void`,由 注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。应用一个批次会返回 Agent setup 提交对象,用于在所有 setup 的 await 均结算后、紧邻 Agent 发布前重新校验配置状态。因此,某项贡献抛出异常或被并发撤销时,会在 Agent 与会话发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose(资源释放)与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 -该 seam 使继续执行管理器无需知道工具名。report 包只安装 `report`;`@deepseek-ai/dsh-tool-subagent-control` 则独立安装 parent 侧的 `send_message` 和 `list_agents`。部署时可安装任一方向、同时安装两者或两者均不安装。提供方仍只负责数据,持久化描述符不会对 report 可用性或投递模式建立快照,冷恢复则使用部署当前的贡献与策略。 +该 seam 使继续执行管理器无需知道工具名。report 包只安装 `report` 及其 child 作用域指引 section;`@deepseek-ai/dsh-tool-subagent-control` 则独立安装 parent 侧的 `send_message` 和 `list_agents`。部署时可安装任一方向、同时安装两者或两者均不安装。提供方仍只负责数据,持久化描述符不会对 report 可用性或投递模式建立快照,冷恢复则使用部署当前的贡献与策略。 ### 快照覆盖 -ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`,按与 `session.N.jsonl` 相同的顺序选择第 N 个已收集 child。它会等待一个包含请求 header 的已闭合 child 轮次,以防可继续 child 早期播种描述符的轮次错误满足该边界。这样,整体组装的静默模式场景无需伪造 parent 可见信号,就能等待 child 侧报告。 +ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`,按与 `session.N.jsonl` 相同的顺序选择第 N 个已收集 child。它会等待一个包含请求 header 的已闭合 child 轮次,以防可继续 child 早期播种描述符的轮次错误满足该边界。这样,整体组装的场景无需伪造 parent 可见信号,就能等待 child 侧报告。 -手写快照会启动一个可继续 child,执行真实的作用域局部 `report` 工具,确认空闲 parent 未被唤醒,然后提交一条后续 parent 提示词,使其消费封装后的报告。它声明 child schema pin `1`,因此本不属于全局的 `report` schema 会与 `tool-schemas.1.expected.json` 比对,root 则继续使用默认 schema pin。生成的工具目录会另外铸造一个 child 作用域,以收录同一个作用域局部 schema。 +手写快照会启动一个可继续 child,执行真实的作用域局部 `report` 工具,观察默认唤醒投递所产生的那一个普通 parent 轮次,然后提交一条后续 parent 提示词,使其消费封装后的报告。它声明 child pin `1`,因此本不属于全局的 `report` schema 与该 child 自身的提示词会分别与 `tool-schemas.1.expected.json` 和 `system-prompt.1.expected.md` 比对,root 则继续使用类别 pin。生成的工具目录会另外铸造一个 child 作用域,以收录同一个作用域局部 schema。 ## 曾考虑的替代方案 @@ -72,7 +72,7 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, ### 始终唤醒 parent -每次报告都唤醒 parent 会产生未经请求的轮次,还可能沿嵌套 subagent 级联扩散。静默投递更适合作为后台协调的默认值,而需要立即处理的部署可选择 wakeup。 +每次报告都唤醒 parent 会产生未经请求的轮次,还可能沿嵌套 subagent 级联扩散。当初选择静默投递作为默认值,前提是 parent 还有别的理由去读自己的上下文。[报告义务](2026-08-06-continuable-child-report-obligation.md)取代了该选择:已经停驻的后台协调者并没有这样的理由,因此唤醒成为默认值,而本段现在记录的是 `quiet` 为何仍然保留。 ### 允许 child 选择投递模式 @@ -103,16 +103,16 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, - 只有安装 report 包贡献时,可继续进程内 child 才会恰好暴露一个作用域局部 `report` schema;无关 Agent 永远不会暴露该 schema。 - 工具返回 parent 消息的稳定 `MessageId`。静默投递没有 `InboxItemId`;唤醒投递会产生一个单独的 inbox 条目实例。 - 只有确切的驻留 child 才能报告,且只能报告给根据持久化谱系推导的确切在线直接 parent。服务不接受接收方参数,也不提供离线 fallback。 -- 静默投递是校验后的默认模式,绝不会启动 parent 请求。wakeup 会恰好创建一个后续 FIFO 轮次,绝不 steering 已开始的轮次。 +- 唤醒投递是校验后的默认模式:它会恰好创建一个后续 FIFO 轮次,绝不 steering 已开始的轮次。静默投递则绝不会启动 parent 请求。 - parent 接受后取消或 dispose child 不会撤回报告。接受前,child dispose、drain、parent 丢失或调用方取消都会拒绝操作。 - 新建和恢复的 Activation 都会在发布前组合当前设置贡献。新授权等待下一个 Activation 才生效,而已驻留 child 的授权撤销立即生效。 - 单元覆盖固定可见性、allow-list 行为、两种投递模式、稳定的消息与发送方身份、嵌套路由、无效发送方、缺失的 parent、取消、drain、撤销竞争,以及不存在 Task 或隐式最终报告。 -- 无密钥整体组装快照证明真实 child 工具、静默且不唤醒的行为、持久化 parent 封装,以及 parent 后续消费。 +- 无密钥整体组装快照证明真实 child 工具、那一个被唤醒的 parent 轮次、持久化 parent 封装,以及 parent 后续消费。 ### 已接受的风险 该接受边界弱于持久化端到端投递。崩溃可能导致结果不明,重试则可能重复报告。 -wakeup 模式可能在嵌套 child 频繁报告时放大模型工作量。由部署所有者控制并默认静默,可以限制该风险,但无法完全消除。 +唤醒投递可能在嵌套 child 频繁报告时放大模型工作量。通过 `reportDelivery` 交由部署所有者控制,可以限制该风险,但无法完全消除。 注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未完成其作用域清理,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml new file mode 100644 index 0000000000..3295cd47d1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md +2026-08-06-continuable-child-report-obligation.md: ba52c72a94350d637699f778342093e71f2ed442 +2026-08-06-continuable-child-report-obligation.zh.md: 5c4178e0e095dac5b1e50ad92ef75d0138940439 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md new file mode 100644 index 0000000000..ba52c72a94 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md @@ -0,0 +1,60 @@ +# Agent Note: The continuable child return channel is an obligation + +Status: implemented + +English | [中文](2026-08-06-continuable-child-report-obligation.zh.md) + +## Problem + +A continuable background child owns its own Session, so nothing it writes there reaches the agent that started it. [The report tool](2026-07-30-continuable-subagent-report-tool.md) gave that child a return channel and then presented it as one option among several: the schema said "call this zero or more times", nothing in the child's prompt asked it to call the tool at all, and the accepted default scheduling (`quiet`) added the report to a parked parent's next request without waking it. + +Each of those choices is defensible alone. Together they made the return channel unusable as a delegation contract. A child that finished its work, wrote its answer into its own transcript, and stopped left the parent with nothing; a child that did report reached a parent that had already parked and would not read the report until something unrelated woke it. External reports of parents busy-polling `list_agents`, re-sending messages to settled children, and abandoning `subagent` for `workflow` all reduce to the same missing guarantee. + +## Decision + +The return channel is an instruction the child receives, not a capability it may discover. The report package installs two scope-local registrations into every continuable in-process child, and one disposer revokes both: + +- the `report` tool, whose description now states that the child calls it once before finishing with a self-contained final result, and earlier for progress that changes what the parent should do next; +- a `tool:report` system-prompt section at order 117 carrying the same obligation in the child's own voice, so a child that never reads tool descriptions closely still receives it. + +`reportDelivery` now defaults to `wakeup`. An accepted report creates exactly one ordinary later parent turn and wakes a parked parent driver; it still never steers an open turn. `quiet` remains available for deployments that prefer unread reports over turn amplification. + +### Why the section and the description both exist + +They address different failure modes. The tool description is read when the model is already considering `report`; the prompt section is read when it is deciding whether it is finished. The obligation belongs at both points because the failure this fixes — a child that simply stops — happens at the second one. + +The section is registered on the child's own scope, the same mechanism [child composition](../../../../packages/subagent/subagent/src/child-agent.ts) already uses for a shadowing persona, so the parent and every sibling see neither the tool nor the guidance. `installReportTool` rolls the section back if tool registration fails, and its returned disposer attempts both revocations before surfacing cleanup failures. + +### Instruction, not enforcement + +Nothing rejects a child that never reports. No runtime path inspects whether a report was sent, and `report` still accepts zero or many calls per turn. The change is model-facing wording plus a scheduling default; the service authority, acknowledgement, and recovery contracts are unchanged. + +That boundary is deliberate: prompt text can only reach a child that is still running its own loop. A child stopped by an error, a token ceiling, cancellation, or teardown never gets the chance to comply. Covering those cases needs an account the runtime keeps itself, which this package cannot supply and does not claim to. + +### Snapshot coverage + +The assembled ACP `subagent-report` scenario now exercises the shipped default: the child reports, the parked parent takes one ordinary turn on that report, and a later prompt still reads the report back out of the durable log. Because the child's scope now composes a prompt the class pin cannot describe, the snapshot harness gained `pinsChildSystemPrompts`, the exact counterpart of the existing `pinsChildToolSchemas`: it moves one child fixture's prompt into `system-prompt..expected.md`, leaves every other request-header field to the class pin, requires the sidecar exactly when declared, and rejects a sidecar identical to that class pin so a redundant copy cannot drift. + +## Alternatives considered + +**Keep `quiet` as the default and rely on the prompt alone.** This was the shipped position, and it supersedes nothing on its own: a report the parent never reads is indistinguishable from a report never sent. The [report-tool note's](2026-07-30-continuable-subagent-report-tool.md) rejection of always-waking assumed the parent had another reason to look at its context; a parked background coordinator does not. Turn amplification is the real cost, and it is now the reason `quiet` still exists rather than the reason it is the default. + +**Let the child choose the delivery mode per call.** Unchanged from the original rejection: the model would own scheduler pressure, and behavior would vary per call rather than per deployment. + +**Put the obligation only in the tool description.** A description is read while choosing among tools. The child this change targets is not choosing a tool; it believes it is done. Prompt guidance is the surface that reaches that decision. + +**Enforce the obligation at settlement by rejecting a silent child.** There is nothing to reject: by the time settlement is observable the child's loop is over, and failing its teardown would destroy work rather than deliver it. Delivering the terminal facts unconditionally from the runtime is the answer to that case, and it belongs to the continuation manager, not to this package. + +## Consequences + +- Every continuable in-process child with this package loaded carries one extra prompt section and a longer `report` description in every request; no other Agent's request changes. +- The default deployment wakes the parent once per accepted report. A nested tree that reports frequently consumes extra parent turns; `quiet` is the documented escape. +- `installReportTool` requires `ctx.systemPrompt` in the child scope, so the package declares `systemPrompt` in `inject` and fails at load rather than at the next child materialization. +- Unit coverage pins the new default, two load-bearing instruction phrases, the section's child-only scope against both the parent and a sibling, and rollback or revocation of both registrations. +- Three assembled ACP scenarios with continuable children pin the complete instruction text through the new sidecar; a future change to any child-scoped section fails those scenarios instead of passing silently. + +### Accepted risks + +Waking by default amplifies model work in deep trees. The deployment owns that through `reportDelivery`, and the amplification is bounded by one turn per accepted report. + +A child can still finish without reporting, and this change cannot detect it. Closing that case needs the runtime's own settlement account, which this package does not own. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md new file mode 100644 index 0000000000..5c4178e0e0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md @@ -0,0 +1,60 @@ +# Agent Note: 可继续 child 的返回通道是一项义务 + +Status: implemented + +[English](2026-08-06-continuable-child-report-obligation.md) | 中文 + +## 问题 + +可继续后台 child 拥有自己的 Session,因此它写在那里的任何内容都不会到达启动它的 agent。[report 工具](2026-07-30-continuable-subagent-report-tool.md)为该 child 提供了一条返回通道,却把它呈现为若干选项之一:schema 里写着「可调用零次或多次」,child 的提示词中没有任何地方要求它调用该工具,而已采纳的默认调度(`quiet`)会把报告加入已停驻 parent 的下一次请求,却不唤醒它。 + +这些选择单独看都站得住脚。合在一起,它们让这条返回通道无法作为委派契约使用。一个完成工作、把答案写进自己 transcript(文本记录)随后停止的 child,会让 parent 一无所获;而确实上报了的 child,面对的是一个已经停驻、要等到别的事件把它唤醒才会读到报告的 parent。外部反馈中的 parent 忙轮询 `list_agents`、反复向已结算 child 发送消息、以及放弃 `subagent` 改用 `workflow`,都可归结为同一处缺失的保证。 + +## 决策 + +返回通道是 child 收到的一条指令,而不是它需要自行发现的能力。report 包会向每个可继续进程内 child 安装两项作用域局部注册,并由同一个 disposer 撤销两者: + +- `report` 工具,其描述现在说明 child 要在结束前调用一次并给出自足的最终结果,并在部分进展会改变 parent 下一步动作时提前调用; +- 一个 order 为 117 的 `tool:report` 系统提示词 section,用 child 自己的语气承载同一条义务,使从不细读工具描述的 child 仍能收到它。 + +`reportDelivery` 的默认值现在是 `wakeup`。一条被接受的报告恰好创建一个普通的后续 parent 轮次并唤醒停驻的 parent 驱动;它仍然绝不 steering(中途引导)已开始的轮次。对于宁可让报告无人阅读也要避免轮次放大的部署,`quiet` 依旧可用。 + +### 为什么 section 与描述同时存在 + +两者针对不同的失效模式。工具描述是在模型已经在考虑 `report` 时被读到的;提示词 section 是在它判断自己是否已经完成时被读到的。这条义务必须同时出现在两处,因为本次修复的失效——child 直接停下——发生在第二处。 + +该 section 注册在 child 自己的作用域上,与[child 组合](../../../../packages/subagent/subagent/src/child-agent.ts)为遮蔽式 persona 已经使用的机制相同,因此 parent 与所有同级都看不到该工具与该指引。工具注册失败时,`installReportTool` 会回滚该 section;它返回的 disposer 会先尝试撤销两项注册,再抛出清理失败。 + +### 是指令,不是强制 + +没有任何东西会拒绝一个从不上报的 child。没有任何运行时路径会检查是否发送过报告,`report` 仍接受一个轮次中调用零次或多次。本次改动是面向模型的措辞加上一个调度默认值;服务权限、确认与恢复契约都保持不变。 + +这条边界是刻意划定的:提示词文本只能到达仍在运行自身循环的 child。被错误、token 上限、取消或拆卸终止的 child 根本没有机会遵守。覆盖这些情形需要由运行时自己保存的记账,而本包无法提供,也不声称提供。 + +### 快照覆盖 + +整体组装的 ACP `subagent-report` 场景现在演练随附的默认行为:child 上报,停驻的 parent 就该报告执行一个普通轮次,随后的提示词仍能从持久化日志中把报告读回来。由于该 child 的作用域现在组合出类别 pin 无法描述的提示词,快照 harness 新增了 `pinsChildSystemPrompts`,它与既有 `pinsChildToolSchemas` 完全对称:把一个 child fixture 的提示词移入 `system-prompt..expected.md`,其余请求 header 字段仍归类别 pin 所有,要求 sidecar 恰好在声明时存在,并拒绝与该类别 pin 完全相同的 sidecar,使冗余副本无法悄悄漂移。 + +## 备选方案 + +**保留 `quiet` 作为默认值,只依赖提示词。** 这曾是随附的立场,而它本身什么也没有解决:一条 parent 从不阅读的报告,与一条从未发送的报告无法区分。[report 工具 Agent Note](2026-07-30-continuable-subagent-report-tool.md)对「始终唤醒」的否决,前提是 parent 还有别的理由去查看自己的上下文;已停驻的后台协调者并没有。轮次放大才是真正的代价,而它现在是 `quiet` 仍然保留的理由,而不是它作为默认值的理由。 + +**让 child 按调用选择投递模式。** 与最初的否决相同:模型将掌握调度压力,行为也会随调用而非随部署变化。 + +**只把义务写在工具描述里。** 描述是在从多个工具中选择时被读到的。本次改动针对的 child 并不在选择工具,它认为自己已经做完了。提示词指引才是能触及该判断的界面。 + +**在结算时拒绝沉默的 child,以此强制该义务。** 没有什么可以拒绝:当结算可被观察时 child 的循环已经结束,让它的拆卸失败只会毁掉工作而不会送达结果。由运行时无条件投递终止事实才是这一情形的答案,而它属于继续执行管理器,不属于本包。 + +## 后果 + +- 加载本包后,每个可继续进程内 child 的每次请求都会多出一个提示词 section 和一段更长的 `report` 描述;其他任何 Agent 的请求都不变。 +- 默认部署会为每条被接受的报告唤醒 parent 一次。频繁上报的嵌套树会消耗额外的 parent 轮次;`quiet` 是有文档记载的退路。 +- `installReportTool` 需要 child 作用域中的 `ctx.systemPrompt`,因此本包在 `inject` 中声明 `systemPrompt`,从而在加载时失败,而不是等到下一次 child 物化时。 +- 单元覆盖固定了新默认值、两处关键指令措辞、该 section 相对 parent 与同级均仅限 child 的作用域,以及两项注册在安装回滚或撤销时的清理。 +- 三个带可继续 child 的整体组装 ACP 场景通过新的 sidecar 逐字固定完整的 child 提示词;今后任何对 child 作用域 section 的改动都会让这些场景失败,而不是悄悄通过。 + +### 已接受的风险 + +默认唤醒会在深层树中放大模型工作量。部署通过 `reportDelivery` 掌握该取舍,且放大幅度以每条被接受报告一个轮次为界。 + +child 仍可能不上报就结束,本次改动无法检测这一点。补上这一情形需要运行时自己的结算记账,而本包并不拥有它。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index bc972314e8..3ce68e5c14 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: cc50a2021cfc378481dc3e830a0973773bae1e02 -config-catalog.zh.md: 5ed4f54a60130927ba710cae1f041da397c2d77a +config-catalog.md: 1646498f379acd628b5c28463b0cabd57d0112a4 +config-catalog.zh.md: 9b8042c616a10da9236b9db0820257a60fd8049d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cc50a2021c..1646498f37 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2324,14 +2324,15 @@ Source: [`packages/subagent/tool-subagent/src/index.ts:25`](../packages/subagent ## `@deepseek-ai/dsh-tool-subagent-report` -Requires: `subagents` · `tools` +Requires: `subagents` · `tools` · `systemPrompt` ```ts config-catalog /** Config: how accepted reports are scheduled on the parent. */ export interface Config { /** - * Parent scheduling (default `quiet`). `quiet` adds context without waking; - * `wakeup` creates one ordinary later parent turn. + * Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later + * parent turn; `quiet` adds context without waking, so a parked parent learns + * of the report only when something else wakes it. */ reportDelivery?: SubagentReportDelivery } @@ -2339,7 +2340,7 @@ export interface Config { Depends on: [`SubagentReportDelivery`](subsystems/subagent.md) -Source: [`packages/subagent/tool-subagent-report/src/index.ts:22`](../packages/subagent/tool-subagent-report/src/index.ts) +Source: [`packages/subagent/tool-subagent-report/src/index.ts:27`](../packages/subagent/tool-subagent-report/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5ed4f54a60..9b8042c616 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2325,14 +2325,15 @@ export interface Config { ## `@deepseek-ai/dsh-tool-subagent-report` -需要:`subagents` · `tools` +需要:`subagents` · `tools` · `systemPrompt` ```ts config-catalog /** Config: how accepted reports are scheduled on the parent. */ export interface Config { /** - * Parent scheduling (default `quiet`). `quiet` adds context without waking; - * `wakeup` creates one ordinary later parent turn. + * Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later + * parent turn; `quiet` adds context without waking, so a parked parent learns + * of the report only when something else wakes it. */ reportDelivery?: SubagentReportDelivery } @@ -2340,7 +2341,7 @@ export interface Config { 依赖:[`SubagentReportDelivery`](subsystems/subagent.md) -来源:[`packages/subagent/tool-subagent-report/src/index.ts:22`](../packages/subagent/tool-subagent-report/src/index.ts) +来源:[`packages/subagent/tool-subagent-report/src/index.ts:27`](../packages/subagent/tool-subagent-report/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index e0a5822779..b2f838dfce 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: cbe1a2d75ef33c44b31ac3b84bb9a54def97d0e5 -module-graph.zh.md: 3c7511cfd391ec69548588df3ab597457a420334 +module-graph.md: f87070e3eda9ab729dd364cee239b0a4adfcca54 +module-graph.zh.md: 8338461ff4fb50e3eab0b0e68d995138881b8f45 diff --git a/docs/module-graph.md b/docs/module-graph.md index cbe1a2d75e..f87070e3ed 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1044,6 +1044,7 @@ flowchart TD pkg_tool_subagent_report --> pkg_invariants pkg_tool_subagent_report --> pkg_llm pkg_tool_subagent_report --> pkg_subagent + pkg_tool_subagent_report --> pkg_system_prompt pkg_tool_subagent_report --> pkg_tools pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol @@ -1428,7 +1429,7 @@ flowchart TD | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`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/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 3c7511cfd3..8338461ff4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1046,6 +1046,7 @@ flowchart TD pkg_tool_subagent_report --> pkg_invariants pkg_tool_subagent_report --> pkg_llm pkg_tool_subagent_report --> pkg_subagent + pkg_tool_subagent_report --> pkg_system_prompt pkg_tool_subagent_report --> pkg_tools pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol @@ -1430,7 +1431,7 @@ flowchart TD | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`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/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index b74b7fa8ea..794985a9e5 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: f61b6daeb7209d0fa81606bda7718fd42c7f22dc -tool-catalog.zh.md: cbecdba084fa64ec78913706221a1558d206cde8 +tool-catalog.md: 58d560832ac8de5dc660a3c3ed75ab829cd262e8 +tool-catalog.zh.md: f2e330251db8a1be842dc05d89014a7be65369c6 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f61b6daeb7..58d560832a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -31,9 +31,9 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `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` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance's description and `run_in_background` parameter follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable`, while `subagent_fork` stays `one-shot` — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. | | `@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-subagent-report` | `report` | `ctx.subagents`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool is installed independently. | +| `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `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.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -1189,7 +1189,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 tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance's description and `run_in_background` parameter follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable`, while `subagent_fork` stays `one-shot` — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-subagent-control` @@ -1268,7 +1268,7 @@ The globally named control tools over continuable background subagents: provider ### `report` -Report selected content to the agent that started you. Call this zero or more times for progress, findings, or a final answer. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it. +Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it. ```json { @@ -1276,7 +1276,7 @@ Report selected content to the agent that started you. Call this zero or more ti "properties": { "output": { "type": "string", - "description": "Self-contained content for your parent; it does not see your private work." + "description": "Actionable content for your parent; summarize conclusions and reference relevant shared paths." } }, "required": [ @@ -1287,7 +1287,7 @@ Report selected content to the agent that started you. Call this zero or more ti Source: [`packages/subagent/tool-subagent-report/src/index.ts`](../packages/subagent/tool-subagent-report/src/index.ts) -Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool is installed independently. +Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. ## `@deepseek-ai/dsh-tool-tasks` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index cbecdba084..f2e330251d 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -33,9 +33,9 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflows`、`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` | `subagent` | `ctx.tools`、`ctx.subagents` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 对应默认值。随产品发布的示例 agent 会为每个 subagent 后端加载一次该包,因此模型还会看到 schema 相同、绑定到 fork 后端的 `subagent_fork`;见 `packages/bundle/base/cordis.patch.yml` 和 `examples/acp-agent/cordis.yml`。 | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`、`ctx.subagents` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 对应默认值。随产品发布的组合会为每个 subagent 后端加载一次该包,因此模型还会看到绑定到 fork 后端的 `subagent_fork`。每个实例的描述与 `run_in_background` 参数取决于它自己的 `backgroundMode` 与 `enableRunInBackground`,因此两个随附 schema 并不相同:`subagent` 为 `continuable`,而 `subagent_fork` 保持 `one-shot`;见 `packages/bundle/base/cordis.patch.yml` 和 `examples/acp-agent/cordis.yml`。 | | `@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-subagent-report` | `report` | `ctx.subagents`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。面向父级的 `send_message` 工具单独安装。 | +| `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`、`task_list`、`task_output` | `ctx.tools`、`ctx.tasks`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制接口:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制接口,从而启用生产方的 `ctx.tasks.start()`。 | | `@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.workflows`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | @@ -1193,7 +1193,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 对应默认值。随产品发布的示例 agent 会为每个 subagent 后端加载一次该包,因此模型还会看到 schema 相同、绑定到 fork 后端的 `subagent_fork`;见 `packages/bundle/base/cordis.patch.yml` 和 `examples/acp-agent/cordis.yml`。 +注册的工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 对应默认值。随产品发布的组合会为每个 subagent 后端加载一次该包,因此模型还会看到绑定到 fork 后端的 `subagent_fork`。每个实例的描述与 `run_in_background` 参数取决于它自己的 `backgroundMode` 与 `enableRunInBackground`,因此两个随附 schema 并不相同:`subagent` 为 `continuable`,而 `subagent_fork` 保持 `one-shot`;见 `packages/bundle/base/cordis.patch.yml` 和 `examples/acp-agent/cordis.yml`。 ## `@deepseek-ai/dsh-tool-subagent-control` @@ -1272,7 +1272,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `report` -向启动你的 agent 报告选定内容。你可以调用 0 次或多次,以报告进度、发现或最终答案。报告不会结束你的轮次或完成你的工作,且只有直接父级会收到。失败的调用仍可能已经送达,因此不要盲目重复。 +向启动你的 agent 报告选定内容。在你结束前调用一次,给出自包含的最终结果;当进度或发现会改变该 agent 接下来的行动时,也可以更早调用。该 agent 与你共享工作区,但不会自动收到你的 transcript(文本记录)、工具输出或推理,因此完成你的工作本身并不等于交出结果。报告不会结束你的轮次或完成你的工作,且只有直接父级会收到。失败的调用仍可能已经送达,因此不要盲目重复。 ```json { @@ -1280,7 +1280,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "properties": { "output": { "type": "string", - "description": "Self-contained content for your parent; it does not see your private work." + "description": "Actionable content for your parent; summarize conclusions and reference relevant shared paths." } }, "required": [ @@ -1291,7 +1291,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent-report/src/index.ts`](../packages/subagent/tool-subagent-report/src/index.ts) -按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。面向父级的 `send_message` 工具单独安装。 +按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 ## `@deepseek-ai/dsh-tool-tasks` diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 59e7b78278..a73d6d36e4 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -121,12 +121,16 @@ backgroundMode: continuable maxDepth: 1 +# Fork stays one-shot because a continuable child's `report` tool and prompt +# section precede the inherited history a fork reuses; `run_in_background` is off +# because this example mounts no task service. See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - backgroundMode: continuable + backgroundMode: one-shot + enableRunInBackground: false maxDepth: 1 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index a05bce3269..3b89ae8a35 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -359,6 +359,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, pinsChildToolSchemas: [1], + pinsChildSystemPrompts: [1], configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, }, // Authored policy-inheritance transcript: the root session is switched to @@ -371,6 +372,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, pinsChildToolSchemas: [1], + pinsChildSystemPrompts: [1], configPath: SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG, }, // The in-process child is published before its first follow-up fails. The @@ -385,13 +387,14 @@ const SCENARIOS: Scenario[] = [ configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, }, // Authored child-to-parent transcript: the child calls its scope-local - // `report`, quiet delivery reaches the idle parent without waking it, and a - // later parent turn consumes the logged report. + // `report`, the default waking delivery gives the parked parent one ordinary + // turn on that report, and a later prompt still reads it back from the log. { name: 'subagent-report', hasModelTurn: true, recorded: false, pinsChildToolSchemas: [1], + pinsChildSystemPrompts: [1], }, // Authored durable-catalog transcript: the snapshot-only lifecycle marker // fences the second parent turn behind the child's Activation end, so @@ -404,6 +407,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, pinsChildToolSchemas: [1], + pinsChildSystemPrompts: [1], }, { name: 'subagent-depth-two-rejection', diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 4abda00a44..2a084e4a9c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357538308,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":5,"time":1786357538310,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357538310,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"b8a6a626-fd2c-4602-a8d4-8074f229bfb5"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538310,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"1b650647-a278-4be1-a76e-bf09540bff75"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357538310,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 0a10247d2e..252bfb326c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357538469,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":5,"time":1786357538470,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357538471,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"d327cdff-ad2e-4f9e-9c54-d4448ee11f2a"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538471,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"c74c4556-1622-4d2e-9745-c5abd087fa83"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357538471,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index b613fbac76..dcd7e67bca 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -141,14 +141,12 @@ interface ToolArgsMap { /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ - run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ task_kill: { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index c979e1db87..5b1ee082a7 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -334,7 +334,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -345,10 +345,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index d5cbdc2ccc..1f323c3ea9 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -277,7 +277,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -288,10 +288,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index cb1b047e93..f02d9ab50a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -124,14 +124,12 @@ interface ToolArgsMap { /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ - run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ task_kill: { diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 335be3672b..f00decd284 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -293,7 +293,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -304,10 +304,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json index 76f60e28d4..0c85c0265d 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -298,7 +298,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -309,10 +309,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json index 84c4671579..74c8cdf4b0 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -277,7 +277,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -288,10 +288,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 37d7d467be..18a14221d9 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -256,7 +256,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -267,10 +267,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index aed34842df..e018ff89a4 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -460,7 +460,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -471,10 +471,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json index dc07c990aa..d0cc2da730 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json @@ -319,7 +319,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -330,10 +330,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/system-prompt.1.expected.md b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/system-prompt.1.expected.md new file mode 100644 index 0000000000..71f4ed05f8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/system-prompt.1.expected.md @@ -0,0 +1,24 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +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. + + +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. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +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. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Deliver your result with the report tool before you finish: call it once with a self-contained answer. The agent that started you shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so a closing remark such as "done" leaves it nothing it can use. Report earlier as well whenever a partial finding changes what that agent should do next; reporting never ends your turn. diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json index 7dd791cf27..6cecb69da2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -194,13 +194,13 @@ }, { "name": "report", - "description": "Report selected content to the agent that started you. Call this zero or more times for progress, findings, or a final answer. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", "parameters": { "type": "object", "properties": { "output": { "type": "string", - "description": "Self-contained content for your parent; it does not see your private work." + "description": "Actionable content for your parent; summarize conclusions and reference relevant shared paths." } }, "required": [ @@ -272,7 +272,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -283,10 +283,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/system-prompt.1.expected.md b/examples/acp-agent/tests/snapshots/subagent-continuable/system-prompt.1.expected.md new file mode 100644 index 0000000000..71f4ed05f8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/system-prompt.1.expected.md @@ -0,0 +1,24 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +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. + + +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. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +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. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Deliver your result with the report tool before you finish: call it once with a self-contained answer. The agent that started you shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so a closing remark such as "done" leaves it nothing it can use. Report earlier as well whenever a partial finding changes what that agent should do next; reporting never ends your turn. diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json index 7dd791cf27..6cecb69da2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json @@ -194,13 +194,13 @@ }, { "name": "report", - "description": "Report selected content to the agent that started you. Call this zero or more times for progress, findings, or a final answer. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", "parameters": { "type": "object", "properties": { "output": { "type": "string", - "description": "Self-contained content for your parent; it does not see your private work." + "description": "Actionable content for your parent; summarize conclusions and reference relevant shared paths." } }, "required": [ @@ -272,7 +272,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -283,10 +283,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index 44d587c851..cfb7fee5a8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357533600,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} {"type":"step/start","seq":5,"time":1786357533602,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357533602,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"82a11d62-da49-4ad3-a243-789ea3cd7c08"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533602,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"722c8476-461f-4a02-904a-f1084b973c95"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357533602,"data":{"title":"Call subagent once. Ask that","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 89de9b1c4f..022584de40 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357533628,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} {"type":"step/start","seq":5,"time":1786357533630,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357533630,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"d5488efe-eea2-4019-8fcb-7e6e49077d8a"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533630,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"fc837f97-bbf9-4c44-9eea-3a8f52c7a77d"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357533630,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/system-prompt.1.expected.md b/examples/acp-agent/tests/snapshots/subagent-list-agents/system-prompt.1.expected.md new file mode 100644 index 0000000000..71f4ed05f8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/system-prompt.1.expected.md @@ -0,0 +1,24 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +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. + + +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. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +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. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Deliver your result with the report tool before you finish: call it once with a self-contained answer. The agent that started you shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so a closing remark such as "done" leaves it nothing it can use. Report earlier as well whenever a partial finding changes what that agent should do next; reporting never ends your turn. diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json index 7dd791cf27..6cecb69da2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json @@ -194,13 +194,13 @@ }, { "name": "report", - "description": "Report selected content to the agent that started you. Call this zero or more times for progress, findings, or a final answer. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", "parameters": { "type": "object", "properties": { "output": { "type": "string", - "description": "Self-contained content for your parent; it does not see your private work." + "description": "Actionable content for your parent; summarize conclusions and reference relevant shared paths." } }, "required": [ @@ -272,7 +272,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -283,10 +283,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index e510929344..442c7f06c8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357524752,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} {"type":"step/start","seq":5,"time":1786357524755,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357524755,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"56ad93fa-0cc0-4ccb-a1b4-258f4801c681"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357524755,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"36cca213-d6e9-491d-9442-87057994f573"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357524755,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index a5f4ab88a4..6e071fa68d 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -27,7 +27,7 @@ {"type":"subagent/descriptor","seq":41,"time":1786357524800,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} {"type":"step/start","seq":42,"time":1786357524803,"data":{"turn":2,"step":1}} {"type":"user/message","seq":43,"time":1786357524803,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} -{"type":"user/message","seq":44,"time":1786358036899,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"6ea5a774-b0da-47ff-84b7-226a4a207bbf"},"surfaceOp":"append"} +{"type":"user/message","seq":44,"time":1786358036899,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"86f2fe9d-3154-41c2-815e-e1c1e18ed895"},"surfaceOp":"append"} {"type":"request/header","seq":45,"time":1786358036900,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","seq":46,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":47,"time0":1783352148077,"data":{"turn":2,"step":1,"index":0,"dt":[0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0,0,1790157964],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index da79d6b23c..e5848d377c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357521754,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} {"type":"step/start","seq":5,"time":1786357521756,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357521756,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"f654b5a4-b4c0-4443-8eab-d84624d804f1"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521756,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"c253c429-6ca2-4ac4-a192-ea1383e90a1e"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357521756,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index f7326e37c8..95f756b06c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357521799,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} {"type":"step/start","seq":5,"time":1786357521801,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357521802,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"05843da4-4a5f-46fc-a00f-5257b2bd271d"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521802,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"488666ba-7318-4b83-bc3f-d7700024b196"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357521802,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/input.json b/examples/acp-agent/tests/snapshots/subagent-report/input.json index b15e0e4d71..15ca4b53eb 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/input.json +++ b/examples/acp-agent/tests/snapshots/subagent-report/input.json @@ -14,6 +14,13 @@ { "op": "waitForSubagentTurnEnd" }, + { + "op": "waitForTurnStart", + "minimumTurn": 2 + }, + { + "op": "waitForTurnEnd" + }, { "op": "promptAndWaitForAgentMessage", "text": "Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools.", diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl index e32d1bdee0..b83358f93d 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl @@ -1,14 +1,14 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785594881508,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785594881508,"data":{}} -{"type":"approval/policy","seq":2,"time":1786357530605,"data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","seq":3,"time":1786357530605,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} -{"type":"turn/start","seq":4,"time":1786357530605,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":5,"time":1786357530605,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":6,"time":1786357530633,"data":{"turn":1,"step":1}} +{"type":"approval/policy","seq":2,"time":1786374158805,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786374158806,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} +{"type":"turn/start","seq":4,"time":1786374158806,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786374158806,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786374158840,"data":{"turn":1,"step":1}} {"type":"user/message","seq":7,"time":1785730453639,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"},"surfaceOp":"append"} -{"type":"user/message","seq":8,"time":1786357530633,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"1677b901-cce7-461a-8b2a-7f119dd9d845"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":1786357530633,"data":{"title":"Call the report tool once","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":8,"time":1786374158840,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"0f107d71-9b56-4ad8-b6f1-d93cb4c82105"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786374158840,"data":{"title":"Call the report tool once","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","seq":10,"time":1785730453639,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":11,"time":1785730453639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":12,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -18,7 +18,7 @@ {"type":"assistant/chunk","seq":16,"time":1785730453647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":17,"time":1785730453647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9e50afb-b732-41ab-b0fc-8e98948ad9ec"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} {"type":"tool/call","seq":18,"time":1785730453647,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} -{"type":"tool/result","seq":19,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 1f4b61e2-6c6d-4db8-836b-ac5760c5e484"}],"isError":false}],"role":"user","id":"cee5f084-bfab-423d-b8bc-1b1b7d88d4fa"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/result","seq":19,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 87627538-d804-4d36-bb10-4768b6fcfb65"}],"isError":false}],"role":"user","id":"22f25be2-d3f9-4558-b3ea-db22fa900aa3"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785730453654,"data":{"turn":1,"step":1}} {"type":"step/start","seq":21,"time":1785730453664,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":22,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl index eb36af2399..edbef518f1 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl @@ -1,10 +1,10 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785730453561,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"5cf78378-e004-4fd5-af4f-cef3b7e190ad"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785730453561,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"e45e4b6d-dae7-42d9-9edb-d24385664025"}]}} {"type":"turn/start","seq":1,"time":1785821411429,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821411429,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730453591,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785730453591,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"5cf78378-e004-4fd5-af4f-cef3b7e190ad"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"d1a851a3-604f-4a42-8e5f-4e480857a3b4"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785730453591,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"e45e4b6d-dae7-42d9-9edb-d24385664025"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"9319509b-82df-4848-8f3a-7654804c9c45"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730453592,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730453592,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730453593,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -13,9 +13,9 @@ {"type":"assistant/chunk","seq":11,"time":1785501592851,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}}}} {"type":"assistant/chunk","seq":12,"time":1785501592851,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1785730453601,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730453602,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"664c39ff-9dac-4bb3-a151-e18a7863d15a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730453602,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"dcd1402a-35c7-4748-bfeb-8734e1018732"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730453602,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":16,"time":1785730453613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"d7927ad2-29db-4de8-ab4d-59a4ebcddd72"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730453613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"b752b617-5282-40b6-bfe0-0535fb069504"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730453613,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730453623,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -23,22 +23,32 @@ {"type":"assistant/chunk","seq":21,"time":1785501592877,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}} {"type":"assistant/chunk","seq":22,"time":1785501592877,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1785730453628,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"time":1785730453628,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"571faad7-adbd-480c-922a-1499e1329ead"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1785730453628,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d5bf8946-4dd4-464e-88de-c7d6191c7671"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785730453628,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":26,"time":1785730453629,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"agent/inbox/spliced","seq":27,"time":1785730453654,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"824dc60a-f9d7-48ea-a0d4-6d56df83bd4f"}]}} -{"type":"agent/inbox/spliced","seq":28,"time":1785730453673,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"43f17984-22c3-48b9-911e-923a2f68dce0"}]}} -{"type":"turn/start","seq":29,"time":1785821411548,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":30,"time":1785730453673,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} -{"type":"agent/inbox/spliced","seq":31,"time":1785821411548,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":32,"time":1785730453683,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":33,"time":1785730453683,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"824dc60a-f9d7-48ea-a0d4-6d56df83bd4f"},"surfaceOp":"append"} -{"type":"user/message","seq":34,"time":1785730453683,"data":{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"43f17984-22c3-48b9-911e-923a2f68dce0"},"surfaceOp":"append"} -{"type":"assistant/chunk","seq":35,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":36,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_REPORT_OK"}}} -{"type":"assistant/chunk","seq":37,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_REPORT_OK"}}}} -{"type":"assistant/chunk","seq":38,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":39,"time":1785730453687,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":40,"time":1785730453687,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"77bba235-d2b6-4a32-9ba3-ebb69d9b0654"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} -{"type":"step/end","seq":41,"time":1785730453687,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":42,"time":1785730453687,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":27,"time":1786008565468,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"2ba9ef7b-dac7-4f39-8035-35f0bf88d0e7"}]}} +{"type":"turn/start","seq":28,"time":1786008565468,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":29,"time":1786008565468,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":30,"time":1786008565474,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":31,"time":1786008565474,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"2ba9ef7b-dac7-4f39-8035-35f0bf88d0e7"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":32,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":33,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"REPORT_RECEIVED"}}} +{"type":"assistant/chunk","seq":34,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"REPORT_RECEIVED"}}}} +{"type":"assistant/chunk","seq":35,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":36,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1786008565479,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"REPORT_RECEIVED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0fe8d69e-6640-4bd5-b72b-70f2cf013cde"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1786008565479,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":39,"time":1786008565479,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":40,"time":1786008565505,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"50d55072-bbf2-4e30-8948-1b837d8db9ee"}]}} +{"type":"turn/start","seq":41,"time":1786008565505,"data":{"turn":3}} +{"type":"agent/inbox/spliced","seq":42,"time":1786008565505,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":43,"time":1786008565511,"data":{"turn":3,"step":1}} +{"type":"user/message","seq":44,"time":1786008565511,"data":{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"50d55072-bbf2-4e30-8948-1b837d8db9ee"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":45,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":46,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_REPORT_OK"}}} +{"type":"assistant/chunk","seq":47,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_REPORT_OK"}}}} +{"type":"assistant/chunk","seq":48,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":49,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":50,"time":1786008565516,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de2ca55e-a346-4f25-911d-a33316b9866b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"step/end","seq":51,"time":1786008565516,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":52,"time":1786008565516,"data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl index 09cd3e4a68..cd63d489c7 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl @@ -2,5 +2,6 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"STARTED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"REPORT_RECEIVED"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CHILD_REPORT_OK"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/system-prompt.1.expected.md b/examples/acp-agent/tests/snapshots/subagent-report/system-prompt.1.expected.md new file mode 100644 index 0000000000..71f4ed05f8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-report/system-prompt.1.expected.md @@ -0,0 +1,24 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +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. + + +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. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +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. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Deliver your result with the report tool before you finish: call it once with a self-contained answer. The agent that started you shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so a closing remark such as "done" leaves it nothing it can use. Report earlier as well whenever a partial finding changes what that agent should do next; reporting never ends your turn. diff --git a/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json index 7dd791cf27..6cecb69da2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json @@ -194,13 +194,13 @@ }, { "name": "report", - "description": "Report selected content to the agent that started you. Call this zero or more times for progress, findings, or a final answer. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "description": "Report selected content to the agent that started you. Call this once before you finish, with a self-contained final result, and earlier for progress or findings that change what that agent does next. That agent shares your workspace but does not automatically receive your transcript, tool output, or reasoning, so finishing your work is not itself a result. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", "parameters": { "type": "object", "properties": { "output": { "type": "string", - "description": "Self-contained content for your parent; it does not see your private work." + "description": "Actionable content for your parent; summarize conclusions and reference relevant shared paths." } }, "required": [ @@ -272,7 +272,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -283,10 +283,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index e0b7a41bcf..4b7d68131f 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -256,7 +256,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -267,10 +267,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index 97c51af14b..9f198a86a5 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -256,7 +256,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", "parameters": { "type": "object", "properties": { @@ -267,10 +267,6 @@ "prompt": { "type": "string", "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." } }, "required": [ diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index b6f1b774f2..61a0190142 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -118,12 +118,16 @@ backgroundMode: continuable maxDepth: 1 +# Fork stays one-shot because a continuable child's `report` tool and prompt +# section precede the inherited history a fork reuses; `run_in_background` is off +# because this example mounts no task service. See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - backgroundMode: continuable + backgroundMode: one-shot + enableRunInBackground: false maxDepth: 1 # The worker-thread workflow engine fans a model-written JavaScript script's diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 8a1c23140b..acbf5f8cc9 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,19 +1,19 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1c70f3f7-2e85-4808-8376-03d4d3bee6e6"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"48a309a8-4004-43e9-87ea-0bd02e9f5857"}]}} {"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1c70f3f7-2e85-4808-8376-03d4d3bee6e6"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786358103673,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"c9a305d4-add2-453e-8789-4e5c127725f7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"48a309a8-4004-43e9-87ea-0bd02e9f5857"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786358103673,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"b8be7a23-617a-4aad-93e6-d12f0593f71d"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1786358103673,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":8,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","seq":13,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c5d4e091-9632-4535-af35-097bc74abdd3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","seq":15,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9f582f43-25e4-4f21-a81c-0186f42fadf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501507,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":17,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 8882bda5af..c5773c21aa 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,19 +1,19 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"12fb8b24-9214-4e43-b3d3-47f2af3531f1"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"11d0804b-a6bb-4dcc-be05-6f35f81f4f13"}]}} {"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"12fb8b24-9214-4e43-b3d3-47f2af3531f1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786358103827,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"14cf8f47-3a7a-4857-a548-02fe407683fb"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"11d0804b-a6bb-4dcc-be05-6f35f81f4f13"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786358103827,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"0d36194f-6afa-41a4-907e-c9e0583b797a"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1786358103827,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":8,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","seq":13,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bb9c39a9-3239-4ee1-939a-bab0046e3028"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","seq":15,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"eb7781b5-d2ac-4a94-8e21-c9f769524a56"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501646,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":17,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 8f0a211969..a3faa19c4b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"2b5f9025-324a-4c76-b883-4af3b5c3060a"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"342d68c5-9a35-46dc-9d80-d18a2375a0cf"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"2b5f9025-324a-4c76-b883-4af3b5c3060a"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"342d68c5-9a35-46dc-9d80-d18a2375a0cf"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b596133f-aafe-4485-9871-ade1dda23373"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0933e3da-0073-49c2-af4a-76c95ea72f02"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"99819f4a-5e53-4a6f-92f6-5be96b765bce"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"716e9e93-6560-4f6d-ad52-0cb8e833da39"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6697e967-e6bd-46b2-8574-18aeb914e7c6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a6cdb2d-c83c-4b7c-af56-2bf3fbece4ae"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"831070a8-3dc0-4275-9f11-0476b47b8ef2"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"045ef2ea-7cfb-4a43-a6ef-d4ef7ad979e1"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cef24cf0-5f9e-4be2-93d8-93a0d89c6e82"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"26ff28fa-fab8-4e92-a72f-d3a5b3352573"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"d7ea395e-840a-46c4-a143-b6e15cf74114"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"e33536a0-0801-4f51-a633-baac1167cfee"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,9 +44,9 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"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-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"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-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"242be4fa-3293-45de-ab55-a017999f2333"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"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-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"492fd6ab-4937-4083-a8e2-395d5d254c3e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"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-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"ee764f11-8827-4984-acef-ec3c5880f5a0"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"31627ac4-1b5d-4213-814c-95599db9a215"}},"sourceEventSeqs":[46],"surfaceOp":"append"} {"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} {"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -54,9 +54,9 @@ {"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6c3dab82-c2ed-492a-ab0d-f235b340a6c1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} +{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0b2fa8a0-7559-4fcf-a34d-bb190f8d9f48"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} {"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"74554b42-640e-4119-a413-ee5ac01e546e"}},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"cfc46278-d37c-4ced-8763-200e37d3290f"}},"sourceEventSeqs":[56],"surfaceOp":"append"} {"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} {"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -64,6 +64,6 @@ {"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1a3bef32-0610-4891-9071-6bdc2e8a8fd2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4c1bdf08-4721-4c52-9f75-56b2c2028c7d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl index 9e39f76b11..5c68474179 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -1,32 +1,32 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"203f911d-905a-43e4-8e1c-fccfe78d445b"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"c5cc1fd3-c9d5-4b49-90b0-34da051cb71e"}]}} {"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"203f911d-905a-43e4-8e1c-fccfe78d445b"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"c5cc1fd3-c9d5-4b49-90b0-34da051cb71e"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6e7dfb28-f033-4dbf-9fd9-6b2f71da6906"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e29c0ce2-2826-4269-a047-a831aec5a4ee"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"4fe4e523-3ac9-421c-8a73-1eb033ac8b09"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"8c3b43ac-1a3b-4955-ad40-78a1d6343e2d"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} {"type":"compact/start","seq":19,"time":1786123401715,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"compact/summary","seq":20,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"b74220bf-4104-4c30-b967-e4570d5eba5d"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"bb3fd14b-4307-4550-9aa9-3326c9ebd92f"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} {"type":"compact/end","seq":22,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"103034c2-e156-4808-ba2e-8e17e7107c9e"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3712f33d-6371-454f-bf8e-707cb5c74b32"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index f4f592570a..bfd47888f9 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,21 +1,21 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d4746327-c682-4874-b8b2-bbc81e98ea03"}]}} {"type":"turn/start","seq":1,"time":1785821457966,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821457966,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730504659,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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."}]},"role":"user","id":"a5ae9c04-0652-436f-9b5a-437a3a6ed235"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d4746327-c682-4874-b8b2-bbc81e98ea03"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730504659,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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."}]},"role":"user","id":"8256fc51-472b-4d50-86ea-b50cef7b1ef6"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730504659,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498587438,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498587438,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730504660,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} {"type":"assistant/chunk","seq":12,"time":1785498587439,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1785730504661,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8cad9650-de5a-4075-8aa3-1b35e67efc2d"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4f71f30a-33e5-4c25-8e66-aa93c993a7e8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730504662,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"1ba1f641-0cf2-496c-895e-3982aa40b0ed"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"e5c2f208-b939-4715-984e-a4537effbbff"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730504671,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730504679,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} {"type":"assistant/chunk","seq":22,"time":1785498587457,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1785730504680,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f5850535-906d-4a78-8518-a733ec91bbd8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b1af47f1-f976-41fc-b6f1-8ddc86f2aecd"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785730504680,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"4f19b0d0-b31d-40b7-84e5-2783131cb363"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"82baaf37-cfd3-4c59-9697-7a1b36fffa38"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":27,"time":1785730504688,"data":{"turn":1,"step":2}} {"type":"step/start","seq":28,"time":1785730504696,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} {"type":"assistant/chunk","seq":32,"time":1785498587473,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":33,"time":1785730504697,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4e0d8eed-877d-4a5b-bd92-e7c4c8f2cf23"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3de44e0c-f4b8-493e-842b-6aaa029569d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","seq":35,"time":1785730504697,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"72be20be-6022-4363-b9bb-1d9f2cce0e20"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"178561b8-fa84-4dc0-9bc8-d183a722d36a"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","seq":37,"time":1785730504704,"data":{"turn":1,"step":3}} {"type":"step/start","seq":38,"time":1785730504712,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} {"type":"assistant/chunk","seq":42,"time":1785498587489,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":43,"time":1785730504713,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"87c2857f-5da3-4050-b081-e044e207be88"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fc614320-c742-470b-a9fc-902fd67d5cb4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","seq":45,"time":1785730504713,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"c6e3ff43-809c-4bd8-ba88-da7294c3385a"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"3f047431-06fc-4201-b4cd-b611b6741ce3"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","seq":47,"time":1785730504721,"data":{"turn":1,"step":4}} {"type":"step/start","seq":48,"time":1785730504730,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,9 +53,9 @@ {"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}} {"type":"assistant/chunk","seq":52,"time":1785498587503,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":53,"time":1785730504731,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5b53e0d5-1c68-4988-9fab-d885a9122fe8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1a4e37b3-72fe-4cbe-bbdb-708088894423"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","seq":55,"time":1785730504731,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"20e3fe4d-3d9d-4771-b75d-1f287fc20048"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"a04c5ae2-3b36-40d0-b570-df158f27d08a"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1785730504738,"data":{"turn":1,"step":5}} {"type":"step/start","seq":58,"time":1785730504746,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -63,9 +63,9 @@ {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}} {"type":"assistant/chunk","seq":62,"time":1785498587517,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":63,"time":1785730504747,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178fb9bc-859c-484d-8376-096a705de30a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0656b271-2099-44fe-a770-834bb6c97e32"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","seq":65,"time":1785730504747,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"584673b3-9221-4a42-b9e4-69ce1b9f4d60"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"8b6397b3-9ee8-431e-975a-200b14670d7e"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","seq":67,"time":1785730504755,"data":{"turn":1,"step":6}} {"type":"step/start","seq":68,"time":1785730504763,"data":{"turn":1,"step":7}} {"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -73,6 +73,6 @@ {"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":72,"time":1785498587531,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} {"type":"assistant/chunk","seq":73,"time":1785730504764,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"23d457ba-b690-4bb9-b434-86f43c9f4da5"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"74b620d5-2737-4b35-b9c9-2829fe59bea5"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} {"type":"step/end","seq":75,"time":1785730504764,"data":{"turn":1,"step":7}} {"type":"turn/end","seq":76,"time":1785730504764,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 2fb5ff10d3..b9448ae6f2 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -311,12 +311,16 @@ toolName: subagent backgroundMode: continuable + # Fork stays one-shot: a continuable child's `report` tool and prompt + # section precede the inherited history a fork exists to reuse; one-shot + # fork children install neither, keeping the parent's request prefix. + # See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - backgroundMode: continuable + backgroundMode: one-shot # Optional direct-child return channel; absent from roots and one-shot agents. - id: tool-subagent-report diff --git a/packages/subagent/subagent-fork/README.i18n.yaml b/packages/subagent/subagent-fork/README.i18n.yaml index e40cf9901f..ca23eabb84 100644 --- a/packages/subagent/subagent-fork/README.i18n.yaml +++ b/packages/subagent/subagent-fork/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-fork/README.md -README.md: 55475aee7841e91960de79887dfe9bf37afdf9da -README.zh.md: 379218970c406162309659277a4fcb2da885d9ea +README.md: 2bd72058ab7d112f8f317a33842fc4d95b719017 +README.zh.md: 40f9e34c5a8ae8c74de2ae0c9e4676866f0bd083 diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 55475aee78..2bd72058ab 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -39,7 +39,7 @@ Forking duplicates retained completed history into separate child requests; the #### KV Cache effect -The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. +The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. Shipped compositions therefore bind this provider to `backgroundMode: one-shot`, because a continuable child additionally carries the child-scoped `report` tool and its prompt section — deltas that precede the inherited history and so invalidate all of it ([the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)). ### Parent tool result, indirectly @@ -58,3 +58,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **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. +- **No shipped composition creates a continuable fork child** — `prepareContinuable` remains implemented and the seam accepts it, but every shipped `cordis.yml` sets `backgroundMode: one-shot` on the fork delegation tool, so the provider's continuable path has no production caller. Reopening it requires the child's system prompt and tool schemas to match the parent's byte for byte, which the [`report` return channel](../tool-subagent-report/README.md) currently prevents. Rationale and the reintroduction condition: [the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md). diff --git a/packages/subagent/subagent-fork/README.zh.md b/packages/subagent/subagent-fork/README.zh.md index 379218970c..40f9e34c5a 100644 --- a/packages/subagent/subagent-fork/README.zh.md +++ b/packages/subagent/subagent-fork/README.zh.md @@ -39,7 +39,7 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 #### KV Cache 影响 -在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。 +在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。因此随附组合把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效(见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md))。 ### 父 agent 工具结果(间接) @@ -58,3 +58,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 ## 已知限制与暂缓事项 - **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。 +- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)。 diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 0a3ea21aa2..b4d3d51144 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -74,6 +74,12 @@ class ForkProvider implements SubagentProvider { }) } + // TODO(fork-continuable-prefix-reuse): no shipped composition calls this — + // they bind fork to `backgroundMode: one-shot` because a continuable child's + // `report` tool and prompt section precede the inherited history, defeating + // the prefix reuse a fork exists for. Reopening needs a byte-identical child + // system prompt and tool schemas; see issue #2124 and + // .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. 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/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 8d64d7e87f..13a892fa8a 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/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-report/README.md -README.md: 1772b72d83563e89ed0e3814e515b06a2b36fe0f -README.zh.md: 94101fbc9611a04c56ed3a9171889499c9144c21 +README.md: b38b6b541d9c03174de1515df7a0babe5da055b4 +README.zh.md: 0c5c063eded1e9057ee8886f03dd6494dd9eeed8 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index 1772b72d83..b38b6b541d 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -2,15 +2,15 @@ English | [中文](README.zh.md) -The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package. +The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it, and installs the prompt section that instructs the child to use it. The package registers a continuable-child setup contribution instead of a global tool, so the tool and its guidance exist only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package. -A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). +The child-scoped `tool:report` prompt section instructs the child to call `report` once before finishing, with a self-contained answer, and earlier whenever a partial finding changes what the parent should do next. The instruction is guidance, not enforcement: the mechanism still accepts zero or many calls in one turn, and no runtime path rejects a child that never reports. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). -`reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. +`reportDelivery` selects parent scheduling for every accepted report. `wakeup` (the default) uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. It is the default because a parent that already parked has no other reason to look, so quiet delivery would leave an accepted report unread until something unrelated woke it. `quiet` uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. Scope-local registration deliberately survives the child's global `toolFilter`, so a delegation allow-list cannot remove the only return channel. A deployment that requires a child with no return channel omits this package. -The contribution body is exported as `installReportTool(childCtx, ctx, delivery)` so inspection consumers can install `report` into a minted child scope. The generated tool catalog uses that path because the global registry cannot expose a scope-local schema. Production composition still enters through `apply()`; the subagent seam's contribution registry remains private. +The contribution body is exported as `installReportTool(childCtx, ctx, delivery)` so inspection consumers can install `report` and its guidance into a minted child scope, and returns the one disposer revoking both. The generated tool catalog uses that path because the global registry cannot expose a scope-local schema. Production composition still enters through `apply()`; the subagent seam's contribution registry remains private. ## Model Experience @@ -18,15 +18,15 @@ The contribution body is exported as `installReportTool(childCtx, ctx, delivery) #### What the model sees -The generated [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report): one required `output` string. Its description states that reporting is explicit and repeatable, reaches only the Agent that started the child, and does not end the turn. It carries no recipient or delivery-mode parameter. +The generated [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report): one required `output` string. Its description states that the child must report once before finishing, that reporting reaches only the Agent that started the child, and that it does not end the turn. It carries no recipient or delivery-mode parameter. The separate `tool:report` prompt section repeats the obligation outside the schema, where a child that ignores tool descriptions still reads it. #### Token effect -Fixed schema cost per continuable-child request, and none in any other Agent's requests. +Fixed schema and prompt-section cost per continuable-child request, and none in any other Agent's requests. #### KV Cache effect -Prefix-stable within a child; the schema does not change at runtime. Removing the package revokes the schema from resident children, which changes their next request prefix. +Prefix-stable within a child; neither the schema nor the section changes at runtime. Removing the package revokes both from resident children, which changes their next request prefix. ### Report result @@ -36,7 +36,7 @@ Prefix-stable within a child; the schema does not change at runtime. Removing th #### Token effect -One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: quiet delivery adds it to the parent's next request, while waking delivery makes it the sole ordinary message of one new parent turn. +One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: waking delivery makes it the sole ordinary message of one new parent turn, while quiet delivery adds it to the parent's next request. #### KV Cache effect @@ -61,6 +61,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del - **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary. - **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report. - **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary. -- **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` only on that child's next Activation, while removing the package revokes the schema from resident children immediately. +- **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` and its guidance only on that child's next Activation, while removing the package revokes both from resident children immediately. - **Nested reporting reaches exactly one edge upward** — a grandchild reports to its direct child parent, never to the top-level coordinator, which must explicitly report a derived update later. -- **No rate limiting** — `wakeup` mode can amplify model work when nested children report frequently; the deployment owns that choice by selecting the mode. +- **No rate limiting** — the default `wakeup` mode can amplify model work when nested children report frequently; a deployment that accepts unread reports over that amplification selects `quiet`. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 94101fbc96..0c5c063ede 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 +可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体),并安装指示子级使用该通道的提示词 section。本包注册的是可继续子级设置贡献,而不是全局工具,因此该工具及其指引只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 -子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 +子级作用域的 `tool:report` 提示词 section 要求子级在结束前调用一次 `report` 并给出自足的答案,并在部分发现会改变父级下一步动作时提前上报。该指令是引导而非强制:机制本身仍接受一个轮次中调用零次或多次,也没有任何运行时路径会拒绝从不上报的子级。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 -`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 +`reportDelivery` 为每条已接受的报告选择父级调度方式。`wakeup`(默认值)使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。之所以作为默认值:已经停驻的父级没有别的理由再去查看,静默投递会让一条已被接受的报告一直无人阅读,直到别的事件把父级唤醒。`quiet` 使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 作用域局部注册有意不受子级全局 `toolFilter` 影响,因此委派允许列表无法移除唯一的返回通道。需要子级不具备返回通道的部署应省略本包。 -贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 安装到新创建的子级作用域中。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。 +贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 及其指引安装到新创建的子级作用域中,并返回同时撤销两者的唯一 disposer。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。 ## 模型体验 @@ -18,15 +18,15 @@ #### 模型看到的内容 -已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明上报需要显式调用且可以重复,只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。 +已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明子级必须在结束前上报一次,上报只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。独立的 `tool:report` 提示词 section 在 schema 之外重申该义务,使忽略工具描述的子级仍能读到。 #### Token 影响 -每个可继续子级请求支付固定的 schema 成本,其他任何 Agent 的请求均无此成本。 +每个可继续子级请求支付固定的 schema 与提示词 section 成本,其他任何 Agent 的请求均无此成本。 #### KV Cache 影响 -子级中的前缀保持稳定;schema 不会在运行时改变。移除本包会从驻留子级中撤销该 schema,从而改变其下一次请求前缀。 +子级中的前缀保持稳定;schema 与该 section 都不会在运行时改变。移除本包会从驻留子级中撤销两者,从而改变其下一次请求前缀。 ### 上报结果 @@ -36,7 +36,7 @@ #### Token 影响 -每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:静默投递会把内容加入父级的下一次请求,唤醒投递则会使该内容成为一个新父级轮次中唯一的普通消息。 +每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:唤醒投递会使该内容成为一个新父级轮次中唯一的普通消息,静默投递则把内容加入父级的下一次请求。 #### KV Cache 影响 @@ -61,6 +61,6 @@ - **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由继续执行管理器拥有的父级,管理器的准入边界会在整片森林拆卸期间拒绝该上报。 - **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。 - **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。 -- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report`;移除本包则会立即从驻留子级撤销该 schema。 +- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report` 及其指引;移除本包则会立即从驻留子级撤销两者。 - **嵌套上报只向上到达一条直接边**:孙级只向作为其直接父级的子级上报,不会直接到达顶层协调器;该直接父级必须随后显式发出一条衍生更新。 -- **没有速率限制**:嵌套子级频繁上报时,`wakeup` 模式会放大模型工作量;部署通过选择模式自行承担这一取舍。 +- **没有速率限制**:嵌套子级频繁上报时,默认的 `wakeup` 模式会放大模型工作量;宁可接受报告无人阅读也要避免这种放大的部署应选择 `quiet`。 diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index f9dcd90588..8c2eb5d999 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -52,6 +53,7 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index b33a5a29ab..6f2a6b3c6a 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -1,7 +1,7 @@ /** - * The child-scoped `report` tool, installed into every continuable in-process - * child's unpublished context. Roots, one-shot children, remote providers, and - * agentless executions never see the registration. + * The child-scoped `report` tool and its usage guidance, installed into every + * continuable in-process child's unpublished context. Roots, one-shot children, + * remote providers, and agentless executions never see the registration. * * @module @deepseek-ai/dsh-tool-subagent-report */ @@ -11,86 +11,131 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentReportDelivery } from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-system-prompt' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'tool-subagent-report' -// The contribution registers only through childCtx.tools, but declaring tools -// makes Loader ordering fail at load instead of the next child materialization. -export const inject = ['subagents', 'tools'] +// The contribution registers only through childCtx.tools and +// childCtx.systemPrompt, but declaring both services makes Loader ordering fail +// at load instead of at the next child materialization. +export const inject = ['subagents', 'tools', 'systemPrompt'] + +/** Guidance order after every per-tool section a continuable child can carry. */ +const REPORT_SECTION_ORDER = 117 /** Config: how accepted reports are scheduled on the parent. */ export interface Config { /** - * Parent scheduling (default `quiet`). `quiet` adds context without waking; - * `wakeup` creates one ordinary later parent turn. + * Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later + * parent turn; `quiet` adds context without waking, so a parked parent learns + * of the report only when something else wakes it. */ reportDelivery?: SubagentReportDelivery } export const Config: z = z.object({ - reportDelivery: z.union(['quiet', 'wakeup'] as const).default('quiet'), + reportDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'), }) /** - * Install `report` into one continuable child's scope. - * @param childCtx - child-scoped context receiving the tool. + * Install `report` and its usage guidance into one continuable child's scope. + * Both registrations are owned by that scope and are therefore invisible to the + * child's parent and siblings. + * @param childCtx - child-scoped context receiving the tool and the guidance. * @param ctx - service context used for delivery. * @param delivery - resolved deployment scheduling policy. - * @returns disposer for this one registration. + * @returns disposer that attempts both child registrations before reporting cleanup failures. */ export function installReportTool( childCtx: Context, ctx: Context, delivery: SubagentReportDelivery, ): () => void { - return childCtx.tools.register(defineTool({ - name: 'report', - description: - 'Report selected content to the agent that started you. Call this zero or more times for progress, ' - + 'findings, or a final answer. Reporting does not end your turn or finish your work, and only your ' - + 'direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.', - parameters: { - output: { - type: 'string', - required: true, - description: 'Self-contained content for your parent; it does not see your private work.', - }, - }, - output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - messageId: { type: 'string', required: true }, + const disposeSection = childCtx.systemPrompt.section({ + name: 'tool:report', + order: REPORT_SECTION_ORDER, + text: 'Deliver your result with the report tool before you finish: call it once with a self-contained ' + + 'answer. The agent that started you shares your workspace but does not automatically receive your ' + + 'transcript, tool output, or reasoning, so a closing remark such as "done" leaves it nothing it can ' + + 'use. Report earlier as well whenever a partial finding changes what that agent should do next; ' + + 'reporting never ends your turn.', + }) + let disposeTool: () => void + try { + disposeTool = childCtx.tools.register(defineTool({ + name: 'report', + description: + 'Report selected content to the agent that started you. Call this once before you finish, with a ' + + 'self-contained final result, and earlier for progress or findings that change what that agent does ' + + 'next. That agent shares your workspace but does not automatically receive your transcript, tool ' + + 'output, or reasoning, so finishing your work is not itself a result. Reporting does not end your ' + + 'turn or finish your work, and only your direct parent receives it. A failed call may still have ' + + 'arrived, so do not blindly repeat it.', + parameters: { + output: { + type: 'string', + required: true, + description: 'Actionable content for your parent; summarize conclusions and reference relevant shared paths.', }, }, - render: (_args, value) => [{ - type: 'text', - text: `report accepted by the agent that started you as message ${value.messageId}`, - }], - }, - async execute(args, exec) { - const content: ContentBlock[] = [{ type: 'text', text: args.output }] - // Scope-local resolution guarantees an Agent. The service still verifies - // its exact live Activation identity at the authority boundary. - const messageId = await ctx.subagents.reportFrom(exec.agent as Agent, content, { - delivery, - signal: exec.signal, - }) - return { messageId } - }, - })) + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + messageId: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ + type: 'text', + text: `report accepted by the agent that started you as message ${value.messageId}`, + }], + }, + async execute(args, exec) { + const content: ContentBlock[] = [{ type: 'text', text: args.output }] + // Scope-local resolution guarantees an Agent. The service still verifies + // its exact live Activation identity at the authority boundary. + const messageId = await ctx.subagents.reportFrom(exec.agent as Agent, content, { + delivery, + signal: exec.signal, + }) + return { messageId } + }, + })) + } catch (error: unknown) { + try { + disposeSection() + } catch (rollbackError: unknown) { + throw new AggregateError( + [error, rollbackError], + 'failed to register the report tool and roll back its prompt guidance', + ) + } + throw error + } + return () => { + const failures: unknown[] = [] + for (const dispose of [disposeTool, disposeSection]) { + try { + dispose() + } catch (error: unknown) { + failures.push(error) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'failed to revoke report tool and prompt registrations') + } + } } /** * Register the continuable-child contribution. - * @param ctx - context carrying tools and the subagent service. + * @param ctx - context carrying tools, the system prompt, and the subagent service. * @param config - deployment scheduling policy. */ export function apply(ctx: Context, config: Config = {}): void { - // Config() applies the schema default ('quiet') at runtime; the schemastery - // return type keeps the input's optional shape, so assert the resolved - // shape here — no runtime fallback exists or is wanted. + // Config() applies the schema default at runtime; the schemastery return + // type keeps the input's optional shape, so assert the resolved one. const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery } ctx.subagents.registerContinuableSetup(childCtx => installReportTool(childCtx, ctx, reportDelivery)) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index f33901c8c7..53a84294cf 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' @@ -96,6 +97,17 @@ function callReport(ctx: Context, child: Agent, output: string, signal = testSig }) } +/** Occupy the child-local report name to force installation rollback. */ +function registerReportConflict(child: Agent): () => void { + return child.ctx.tools.register({ + name: 'report', + description: 'conflicting report fixture', + parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'object', properties: {} }, render: () => [] }, + execute: () => Promise.resolve({}), + }) +} + /** Reports already visible or still pending in one Agent. */ function reports(agent: Agent): { id: string; text: string; sender: string }[] { const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) @@ -113,6 +125,12 @@ function renderedText(result: { content: { type: string; text?: string }[] }): s return result.content.flatMap(block => block.type === 'text' ? [block.text ?? ''] : []).join('') } +/** The prompt sections one agent's scope assembles, by name. */ +async function sectionNames(ctx: Context, agent: Agent): Promise { + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) + return assembly.sections.map(section => section.name) +} + describe('dsh-tool-subagent-report', () => { it('registers report only in continuable child scopes', async () => { const { ctx, parent } = await setup() @@ -309,16 +327,101 @@ describe('dsh-tool-subagent-report', () => { const { ctx, parent, fiber } = await setup() const { child } = await startChild(ctx, parent) expect(ctx.tools.schemas(child).map(schema => schema.name)).toContain('report') + expect(await sectionNames(ctx, child)).toContain('tool:report') await fiber?.dispose() expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report') + expect(await sectionNames(ctx, child)).not.toContain('tool:report') expect((await callReport(ctx, child, 'revoked')).isError).toBe(true) const late = await ctx.plugin(tool, { reportDelivery: 'quiet' }) expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report') + expect(await sectionNames(ctx, child)).not.toContain('tool:report') await late.dispose() }) + it('rolls back prompt guidance when tool registration fails', async () => { + const { ctx, parent } = await setup({ load: false }) + const { child } = await startChild(ctx, parent) + const disposeConflict = registerReportConflict(child) + + expect(() => tool.installReportTool(child.ctx, ctx, 'quiet')).toThrow(/already registered in this scope/) + expect(await sectionNames(ctx, child)).not.toContain('tool:report') + disposeConflict() + }) + + it('aggregates a registration failure with a prompt rollback failure', async () => { + const { ctx, parent } = await setup({ load: false }) + const { child } = await startChild(ctx, parent) + const disposeConflict = registerReportConflict(child) + const rollbackFailure = new Error('prompt rollback listener failed') + let promptChanges = 0 + const off = ctx.on('system-prompt/change', () => { + promptChanges++ + if (promptChanges === 2) throw rollbackFailure + }) + + let failure: unknown + try { + tool.installReportTool(child.ctx, ctx, 'quiet') + } catch (error: unknown) { + failure = error + } + off() + + expect(failure).toBeInstanceOf(AggregateError) + if (!(failure instanceof AggregateError)) throw new Error('expected aggregate installation failure') + expect(failure.errors).toHaveLength(2) + expect(String(failure.errors[0])).toContain('already registered in this scope') + expect(failure.errors[1]).toBe(rollbackFailure) + expect(await sectionNames(ctx, child)).not.toContain('tool:report') + disposeConflict() + }) + + it('attempts both revocations and aggregates change-listener failures', async () => { + const { ctx, parent } = await setup({ load: false }) + const { child } = await startChild(ctx, parent) + const dispose = tool.installReportTool(child.ctx, ctx, 'quiet') + const toolFailure = new Error('tool removal listener failed') + const promptFailure = new Error('prompt removal listener failed') + const offTool = ctx.on('tools/change', () => { throw toolFailure }) + const offPrompt = ctx.on('system-prompt/change', () => { throw promptFailure }) + + let failure: unknown + try { + dispose() + } catch (error: unknown) { + failure = error + } + offPrompt() + offTool() + + expect(failure).toBeInstanceOf(AggregateError) + if (!(failure instanceof AggregateError)) throw new Error('expected aggregate revocation failure') + expect(failure.errors).toEqual([toolFailure, promptFailure]) + expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report') + expect(await sectionNames(ctx, child)).not.toContain('tool:report') + }) + + it('scopes the report guidance to the child that owns it', async () => { + const { ctx, parent } = await setup() + const { child } = await startChild(ctx, parent, 'first child') + const { child: sibling } = await startChild(ctx, parent, 'second child') + + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(child)) + const guidance = assembly.sections.find(section => section.name === 'tool:report') + // Pins the model-visible instruction that makes the return channel a + // contract rather than an option the child may quietly skip. + expect(guidance?.text).toContain('Deliver your result with the report tool before you finish') + expect(guidance?.text).toContain('reporting never ends your turn') + + expect(await sectionNames(ctx, parent)).not.toContain('tool:report') + // A sibling installs its own copy; neither child can observe the other's. + expect(await sectionNames(ctx, sibling)).toContain('tool:report') + expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)) + .not.toContain('tool:report') + }) + it('rolls back materialization when a setup contribution revokes itself', async () => { const { ctx, parent } = await setup({ load: false }) const self: { revoke?: () => void } = {} @@ -405,10 +508,29 @@ describe('dsh-tool-subagent-report', () => { it('keeps the namespace plugin shape and validates its default', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-report') - expect(tool.inject).toEqual(['subagents', 'tools']) - expect(tool.Config({}).reportDelivery).toBe('quiet') + expect(tool.inject).toEqual(['subagents', 'tools', 'systemPrompt']) + // Waking is the default because a report that never wakes its parent + // cannot deliver a result to an agent that already parked. + expect(tool.Config({}).reportDelivery).toBe('wakeup') expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow() }) + + it('wakes the parent under the default configuration', async () => { + const { ctx, parent, adapter } = await setup({ config: {} }) + const { child } = await startChild(ctx, parent) + const enqueues: string[] = [] + ctx.on('agent/inbox/inserted', ({ agent, message }) => { + if (agent === parent) { + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') + } + }) + + expect((await callReport(ctx, child, 'DEFAULT_WAKES')).isError).toBe(false) + expect(enqueues).toEqual(['queued']) + await vi.waitFor(() => { + expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true) + }) + }) }) /** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */ diff --git a/packages/subagent/tool-subagent-report/tsconfig.json b/packages/subagent/tool-subagent-report/tsconfig.json index 9ccaad69f0..1a2a08671a 100644 --- a/packages/subagent/tool-subagent-report/tsconfig.json +++ b/packages/subagent/tool-subagent-report/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../core/system-prompt" + }, { "path": "../../core/tools" }, diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 5898c5beb1..ca9e9e67f0 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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/support/acp-snapshot/README.md -README.md: 8a9669b1f555fceb7af6d017d273b62d7dcad1e1 -README.zh.md: efa31df86d3b494ab14fc3742fc84adce570a593 +README.md: 06f1cb67cfcd954254db480ea696d10d81b37438 +README.zh.md: c4a5643f8c5d7e5b62a160cd32e5969187d34046 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 8a9669b1f5..06f1cb67cf 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,6 +55,8 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. +A child session whose own scope composes a different request declares it per fixture index: `pinsChildToolSchemas` moves that child's tool sequence into `tool-schemas..expected.json`, and `pinsChildSystemPrompts` moves its prompt into `system-prompt..expected.md`. Each names the `session..jsonl` fixture it describes, leaves every other request-header field to the class pin, and requires its sidecar to exist exactly when declared. A child prompt sidecar must also differ from its class pin, so a redundant copy fails instead of drifting. A continuable child carrying the scope-local `report` tool and its guidance section is the shipped case for both. + Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. A scenario whose composition needs a usable `pwsh` declares `pwshOnly`; the caller-supplied `hasPwsh` probe (the shipped acp-agent suite follows the executor's own resolution, so Program Files installs count) skips the run test when no usable `pwsh` resolves while the fixture guards keep covering its committed files everywhere. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index efa31df86d..c4a5643f8c 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,6 +55,8 @@ defineAcpSnapshotSuite({ 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 +自身作用域组合出不同请求的 child 会话按 fixture 索引单独声明:`pinsChildToolSchemas` 把该 child 的工具序列移入 `tool-schemas..expected.json`,`pinsChildSystemPrompts` 把其提示词移入 `system-prompt..expected.md`。两者都指名自己描述的 `session..jsonl` fixture,其余请求 header 字段仍归类别 pin 所有,并要求 sidecar 恰好在声明时存在。child 提示词 sidecar 还必须与其类别 pin 不同,因此冗余副本会直接失败,而不会悄悄漂移。携带作用域局部 `report` 工具及其指引 section 的可继续 child 是两者的随附用例。 + 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;调用方提供的 `hasPwsh` 探测(随附的 acp-agent 套件遵循执行器自身的解析,因此 Program Files 安装也计入)在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 5cdffadf38..1728671c3a 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -46,6 +46,11 @@ function childToolSchemasSnapshot(index: number): string { return `tool-schemas.${index}.expected.json` } +/** Return the dedicated system-prompt sidecar for one child fixture index. */ +function childSystemPromptSnapshot(index: number): string { + return `system-prompt.${index}.expected.md` +} + /** The optional full Windows-native stdout transcript. */ const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl' @@ -116,6 +121,13 @@ export interface Scenario { * request-header field. */ pinsChildToolSchemas?: readonly number[] + /** + * Child fixture indices whose own system prompt is pinned separately, where + * `1` names `session.1.jsonl` and `system-prompt.1.expected.md`. A child + * scope that installs its own prompt section (the continuable `report` + * guidance) composes a prompt the class pin cannot describe. + */ + pinsChildSystemPrompts?: readonly number[] /** * How many changed `request/header` snapshots this PINNING scenario's primary * fixture legitimately carries (default 0). Their full prompt text is kept in @@ -491,6 +503,18 @@ export function formatSystemPromptSnapshot( return snapshot } +/** + * Reject a child prompt sidecar that cannot own distinct, canonical prompt text. + * @param sidecar - committed child prompt snapshot. + * @param classPin - initial prompt snapshot owned by the scenario's header class. + * @param label - repository-relative fixture label for diagnostics. + */ +export function assertChildSystemPromptSnapshot(sidecar: string, classPin: string, label: string): void { + if (sidecar.trim().length === 0) throw new Error(`${label} must pin a non-empty prompt`) + if (!sidecar.endsWith('\n')) throw new Error(`${label} must end in a newline`) + if (sidecar === classPin) throw new Error(`${label} must differ from its class pin`) +} + /** Return the initial-prompt portion of a possibly multi-header snapshot. */ function initialSystemPromptSnapshot(snapshot: string): string { const marker = snapshot.indexOf('\n diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 39e8ff1e88..2d9eb45c4a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -748,7 +748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:792`](../../packages/core/session/src/index.ts) @@ -777,7 +777,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:54`](../../packages/core/session/src/index.ts) @@ -800,7 +800,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/src/index.ts) @@ -825,7 +825,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:76`](../../packages/core/session/src/index.ts) @@ -847,5 +847,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 86ecf90b02..be4a4e74ff 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: 42359215b2e21028e13734c2a4cd2488f86a07af -subagent.zh.md: 0d1d4c42fa774867679ff248bfc99b0024b04257 +subagent.md: fbfe83010fc297b6c10baf0631e8cdfd4d05854c +subagent.zh.md: fc2fc85e35eaee1467921dc9f94b3d2a40292bbb diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 42359215b2..fbfe83010f 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -209,6 +209,27 @@ interface SubagentReportMessageSource { type SubagentReportDelivery = 'quiet' | 'wakeup' ``` +Reporting is the child's own choice, so the manager keeps a separate account of its own: when a resident Activation settles, it delivers one notice to the child's durable direct parent describing how that epoch ended and carrying its final assistant content. 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-admission accounting as a report. A parent whose own lineage is already tearing down receives it without a wake, because waking a quiescent Agent starts a turn rather than queueing work. Its provenance is 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 SubagentReportMessageSource}: a report is content the child 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 +} +``` + ```ts type-equiv /** Options for one continuable child's report to its direct parent. */ interface SubagentReportOptions { @@ -619,7 +640,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) -Source: [`packages/subagent/subagent/src/index.ts:170`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:171`](../../packages/subagent/subagent/src/index.ts) @@ -645,7 +666,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:166`](../../packages/subagent/subagent/src/index.ts) @@ -662,7 +683,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) @@ -679,7 +700,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:146`](../../packages/subagent/subagent/src/index.ts) @@ -703,5 +724,5 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:157`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 0d1d4c42fa..fc2fc85e35 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -209,6 +209,27 @@ interface SubagentReportMessageSource { type SubagentReportDelivery = 'quiet' | 'wakeup' ``` +上报是 child 自己的选择,因此管理器还保有一份属于自己的记账:当驻留 Activation 结算时,它会向该 child 持久化的直接 parent 投递一条通知,说明该 epoch 如何结束,并携带其最终 assistant 内容。对每个调用方拿到过 id 的 child,这条投递都是无条件的;它发生在会让 parent 被判定为已结算的所有权释放之前,并通过与上报相同的唤醒准入记账到达驻留 parent。若 parent 自身所在的谱系已在拆卸中,这条通知会以不唤醒的方式送达,因为唤醒一个静息 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 SubagentReportMessageSource}: a report is content the child 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 +} +``` + ```ts type-equiv /** Options for one continuable child's report to its direct parent. */ interface SubagentReportOptions { @@ -621,7 +642,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) -Source: [`packages/subagent/subagent/src/index.ts:170`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:171`](../../packages/subagent/subagent/src/index.ts) @@ -647,7 +668,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:166`](../../packages/subagent/subagent/src/index.ts) @@ -664,7 +685,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) @@ -681,7 +702,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:146`](../../packages/subagent/subagent/src/index.ts) @@ -705,5 +726,5 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:157`](../../packages/subagent/subagent/src/index.ts) diff --git a/examples/acp-agent/subagent-report-quiet.cordis.snapshot.yml b/examples/acp-agent/subagent-report-quiet.cordis.snapshot.yml new file mode 100644 index 0000000000..4d28b399f0 --- /dev/null +++ b/examples/acp-agent/subagent-report-quiet.cordis.snapshot.yml @@ -0,0 +1,51 @@ +# Keyless counterpart to subagent-report-quiet.cordis.yml: replace the live +# adapter with replay, keep report delivery quiet, and fence the child behind +# the end of its parent's spawn turn so settlement opens the next turn. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + config: + reportDelivery: quiet + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + +- id: report-fence + name: './tests/fixtures/subagent-report-fence.ts' diff --git a/examples/acp-agent/subagent-report-quiet.cordis.yml b/examples/acp-agent/subagent-report-quiet.cordis.yml new file mode 100644 index 0000000000..cb1c2b6d34 --- /dev/null +++ b/examples/acp-agent/subagent-report-quiet.cordis.yml @@ -0,0 +1,17 @@ +# Snapshot-only overlay pinning quiet report delivery. The shipped default wakes +# the parent on every accepted report, and the runtime's settlement notice wakes +# it again when the child's Activation ends; two independent wakes have no single +# authored order. Quiet delivery leaves settlement as the only wake, while the +# fixture below holds the child until the parent's spawn turn has closed. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + config: + reportDelivery: quiet + +- id: report-fence + name: './tests/fixtures/subagent-report-fence.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3b89ae8a35..b1df6b0eef 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -44,6 +44,9 @@ const CHILD_QUESTION_CONFIG = fileURLToPath(new URL('../child-question.cordis.ym const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) +const SUBAGENT_REPORT_QUIET_CONFIG = fileURLToPath( + new URL('../subagent-report-quiet.cordis.yml', import.meta.url), +) const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( new URL('../subagent-durability-failure.cordis.yml', import.meta.url), ) @@ -353,7 +356,13 @@ const SCENARIOS: Scenario[] = [ // turns on that same child (the parent is never woken with their output), // send_message to an unknown subagent id fails without delivering, and the // child's retained handle is disposed child-first at teardown despite a - // failed final durability confirmation. + // failed final durability confirmation. That failed confirmation is also what + // the settlement notice must report: the child's last turn claimed the third + // message and then died on its durability checkpoint without entering a step, + // so the notice opening the parent's second turn says the child FAILED and the + // parent must not read the earlier answer as final. The scenario's fixture + // fences the child behind the parent's spawn turn so that notice can only + // arrive at an idle parent. { name: 'subagent-continuable', hasModelTurn: true, @@ -367,6 +376,8 @@ const SCENARIOS: Scenario[] = [ // continuable background child's log carries that override as a // `sandbox/mode` `source: 'delegation'` event, so the child's runtime // context states the inherited policy instead of the deployment default. + // The input also waits for the manager-owned settlement turn, keeping that + // delivery from racing transcript harvest. { name: 'subagent-continuable-inheritance', hasModelTurn: true, @@ -387,12 +398,16 @@ const SCENARIOS: Scenario[] = [ configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, }, // Authored child-to-parent transcript: the child calls its scope-local - // `report`, the default waking delivery gives the parked parent one ordinary - // turn on that report, and a later prompt still reads it back from the log. + // `report`, and the runtime's unconditional settlement notice then wakes the + // parked parent into one ordinary turn that claims both. The overlay pins + // quiet report delivery because two independent wakes have no orderable + // transcript; the shipped waking default is covered by package tests. { name: 'subagent-report', hasModelTurn: true, recorded: false, + overridden: false, + configPath: SUBAGENT_REPORT_QUIET_CONFIG, pinsChildToolSchemas: [1], pinsChildSystemPrompts: [1], }, diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 8f5185026e..9ebc0d6ac1 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -31,6 +31,8 @@ const FAILED_CHECKPOINT_TURN = 3 /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { const followupsAccepted = Promise.withResolvers() + const parentTurnClosed = Promise.withResolvers() + let parentClosed = false const publishedFailure = process.env.DSH_SUBAGENT_PUBLISHED_FAILURE === '1' const persistence = ctx.sessionPersistence const load = persistence.load.bind(persistence) @@ -62,8 +64,22 @@ export function apply(ctx: Context): void { agents.create = create persistence.load = load followupsAccepted.resolve(undefined) + parentTurnClosed.resolve(undefined) }, 'subagent snapshot ordering') + // The manager's settlement notice races whatever the parent is doing when the + // child's Activation ends, and this transcript pins it as the parent's own + // later turn. Hold the child's steps until the parent's spawn turn closes, so + // the notice can only arrive at an idle parent. The parent's turn never awaits + // child model work — its own fences need inbox acceptance only — so the child + // cannot deadlock it. + ctx.on('session/event', (session, event) => { + if (session.header.parentSession !== undefined || event.type !== 'turn/end') return + if (event.data.turn !== 1) return + parentClosed = true + parentTurnClosed.resolve(undefined) + }) + // Remap the placeholder child id in a follow-up to the live child. The child // id the model "knows" is authored into the transcript, while the running // child is minted with a random id, so without this the follow-ups would @@ -91,7 +107,12 @@ export function apply(ctx: Context): void { if (accepted >= 3) followupsAccepted.resolve(undefined) }) ctx.on('agent/pre-step', async ({ agent }, next) => { - if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise + if (agent.session.header.parentSession === undefined) return next() + await followupsAccepted.promise + // The published-failure variant's child never reaches a step (its follow-up + // throws), and its parent turn awaits that child, so only the continuable + // scenario takes the settlement fence. + if (!publishedFailure && !parentClosed) await parentTurnClosed.promise return next() }) diff --git a/examples/acp-agent/tests/fixtures/subagent-report-fence.ts b/examples/acp-agent/tests/fixtures/subagent-report-fence.ts new file mode 100644 index 0000000000..754343e4ec --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent-report-fence.ts @@ -0,0 +1,41 @@ +/** + * Loader fixture that holds the report child until its parent's spawn turn ends. + * @module subagent-report-fence + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent-loop' + +/** Fixture plugin name. */ +export const name = 'subagent-report-fence' + +/** + * Keep replay scheduling from folding settlement into the parent's first turn. + * @param ctx - assembled ACP-agent context. + */ +export function apply(ctx: Context): void { + const childReady = Promise.withResolvers() + const parentStopped = Promise.withResolvers() + let hasStopped = false + + ctx.effect(() => { + const disposeSession = ctx.root.on('session/event', (session, event) => { + if (session.header.parentSession !== undefined || event.type !== 'turn/end' || event.data.turn !== 1) return + hasStopped = true + parentStopped.resolve(undefined) + }) + const disposeStep = ctx.root.on('agent/pre-step', async ({ agent, turn, step }, next) => { + if (agent.session.header.parentSession !== undefined) { + childReady.resolve(undefined) + if (!hasStopped) await parentStopped.promise + } else if (turn === 1 && step === 2) { + await childReady.promise + } + return next() + }) + return () => { + disposeStep() + disposeSession() + } + }, 'subagent-report-fence.listeners') +} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 2a084e4a9c..41a574fef4 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357538308,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":5,"time":1786357538310,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357538310,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"1b650647-a278-4be1-a76e-bf09540bff75"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538310,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"a74dc391-2558-432c-b665-603b52eba070"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357538310,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 252bfb326c..97bd2468c6 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357538469,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":5,"time":1786357538470,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357538471,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"c74c4556-1622-4d2e-9745-c5abd087fa83"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538471,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"c2882641-5206-4d05-9b81-03898bc2943e"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357538471,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index aea9e3107c..56ae9d71c6 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821417919,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498801761,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"06416873-c855-452d-8996-ea5cf45223d1"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"b6de532e-08ff-42b0-abae-1895dc463500"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730458430,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498801765,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730458431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index dcd7e67bca..e77d9bb220 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -132,13 +132,13 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ + /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */ run_in_background?: boolean; } & Record; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 5b1ee082a7..e5d06a90bf 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -309,7 +309,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -323,7 +323,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 1f323c3ea9..a655e8a4cc 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -252,7 +252,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -266,7 +266,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index f02d9ab50a..6737c824ea 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -115,13 +115,13 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ + /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */ run_in_background?: boolean; } & Record; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index f00decd284..5cae77c81b 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -268,7 +268,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -282,7 +282,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json index 0c85c0265d..27fdd9aa04 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -231,7 +231,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -245,7 +245,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json index 74c8cdf4b0..e7fadcd731 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -231,7 +231,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -245,7 +245,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 18a14221d9..d7be239de3 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -231,7 +231,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -245,7 +245,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index e018ff89a4..96fd6b34de 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -435,7 +435,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -449,7 +449,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json index d0cc2da730..698b522fb3 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json @@ -294,7 +294,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -308,7 +308,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json index 183e21b557..f07f1d51fc 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json @@ -14,6 +14,13 @@ "op": "waitForSubagentTurnEnd", "child": 1, "minimumTurn": 1 + }, + { + "op": "waitForTurnStart", + "minimumTurn": 2 + }, + { + "op": "waitForTurnEnd" } ] } diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl index 15b1748ea5..293f9c3b83 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl @@ -27,3 +27,16 @@ {"type":"assistant/message","seq":25,"time":1786333735904,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4057a08e-b50e-45e7-beb0-c74485f2b7d6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} {"type":"step/end","seq":26,"time":1786333735904,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":27,"time":1786333735904,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":28,"time":1786374357751,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 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 33333333-3333-4333-8333-333333333333 finished and will do no further work unless you send it more.","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"e0bd4902-daba-4e23-bfcb-9e102fdd203d"}]}} +{"type":"turn/start","seq":29,"time":1786374357751,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":30,"time":1786374357751,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":31,"time":1786374357758,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":32,"time":1786374357758,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 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 33333333-3333-4333-8333-333333333333 finished and will do no further work unless you send it more.","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"e0bd4902-daba-4e23-bfcb-9e102fdd203d"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":33,"time":1786374357764,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":34,"time":1786374357764,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SUBAGENT_SETTLED_NOTED"}}} +{"type":"assistant/chunk","seq":35,"time":1786374357764,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"type":"assistant/chunk","seq":36,"time":1786374357764,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":37,"time":1786374357764,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":38,"time":1786374357764,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bbe5ef7b-2a3a-47f4-8475-60945b31a373"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[33,34,35,36,37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":1786374357765,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":40,"time":1786374357765,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl index 82ae8907ca..d6a2728b5a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl @@ -2,3 +2,4 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json index 6cecb69da2..d0b02de9dd 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -247,7 +247,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -261,7 +261,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json index f1354bd76a..380a7825c2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json @@ -10,6 +10,17 @@ "op": "prompt", "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool." }, - { "op": "waitForSubagentTurnEnd", "child": 1, "minimumTurn": 3 } + { + "op": "waitForSubagentTurnEnd", + "child": 1, + "minimumTurn": 3 + }, + { + "op": "waitForTurnStart", + "minimumTurn": 2 + }, + { + "op": "waitForTurnEnd" + } ] } diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index a759c35a32..60a55d1373 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -1,10 +1,10 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785730451297,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"125665d3-8c03-4190-b4f9-c27d61d245f4"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785730451297,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"579d3d6d-a57e-4d55-9b48-05832a79d9f8"}]}} {"type":"turn/start","seq":1,"time":1785821408972,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821408972,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730451327,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785730451327,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"125665d3-8c03-4190-b4f9-c27d61d245f4"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730451328,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"533e9513-a329-4a36-9a8d-ddaf544b57c3"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785730451327,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"579d3d6d-a57e-4d55-9b48-05832a79d9f8"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730451328,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"c4f5f7ed-1c11-4f31-923f-3142c79f0c2c"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730451328,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730451329,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730451329,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -13,9 +13,9 @@ {"type":"assistant/chunk","seq":11,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} {"type":"assistant/chunk","seq":12,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1785730451338,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730451338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4a2c2f8b-66be-4860-9bbf-b83feb56009e"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730451338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"680da987-6d29-4141-b83d-af57b050c712"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730451338,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":16,"time":1785730451348,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"b54a0d61-4233-40f1-ab3a-3eb58e1b0c61"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730451348,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"7825edb2-080e-49c1-ba74-ad69d16bf566"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730451348,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730451360,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","seq":21,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} {"type":"assistant/chunk","seq":22,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1785730451364,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785730451364,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"398dea92-a100-4b2f-a9e1-72629def132d"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1785730451364,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ef6eadc7-165e-4705-b865-3889f0af0f36"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785730451365,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} -{"type":"tool/result","seq":26,"time":1785730451377,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_followup_1"},"content":[{"type":"tool-result","toolCallId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"45b388fd-6a48-4a02-9f3b-1d642e797c71"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","seq":26,"time":1785730451377,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_followup_1"},"content":[{"type":"tool-result","toolCallId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"ac1214a5-1d91-4fab-8f96-833baca114f8"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":27,"time":1785730451377,"data":{"turn":1,"step":2}} {"type":"step/start","seq":28,"time":1785730451390,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":29,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","seq":31,"time":1785544945241,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} {"type":"assistant/chunk","seq":32,"time":1785544945242,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":33,"time":1785730451394,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":1785730451394,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f56cec19-e761-4c77-9237-07d12d334275"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","seq":34,"time":1785730451394,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"33939813-0792-4ac5-8864-ec62a4ddff8e"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","seq":35,"time":1785730451395,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} -{"type":"tool/result","seq":36,"time":1785730451406,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_followup_2"},"content":[{"type":"tool-result","toolCallId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"193df31b-de9a-4723-b2b1-c7c96a8eaee4"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","seq":36,"time":1785730451406,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_followup_2"},"content":[{"type":"tool-result","toolCallId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"a6f64c64-f50c-47cd-a6b3-a3b57d3dc83d"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","seq":37,"time":1785730451406,"data":{"turn":1,"step":3}} {"type":"step/start","seq":38,"time":1785730451419,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":39,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","seq":41,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} {"type":"assistant/chunk","seq":42,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":43,"time":1785730451424,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":44,"time":1785730451424,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cbf54e23-fb96-45cc-b629-b9cb48fc9876"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","seq":44,"time":1785730451424,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"95eab91d-b103-4033-8e2e-c9c93b1b0211"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","seq":45,"time":1785730451425,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} -{"type":"tool/result","seq":46,"time":1785730451437,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_followup_unknown"},"content":[{"type":"tool-result","toolCallId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true}],"role":"user","id":"d5d961af-c171-40b6-87a1-33d2c14740a7"},"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","seq":46,"time":1785730451437,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_followup_unknown"},"content":[{"type":"tool-result","toolCallId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true}],"role":"user","id":"8a095e4b-3059-420d-856f-1cbd20b6a2e2"},"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","seq":47,"time":1785730451437,"data":{"turn":1,"step":4}} {"type":"step/start","seq":48,"time":1785730451450,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":49,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -53,6 +53,19 @@ {"type":"assistant/chunk","seq":51,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":52,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":53,"time":1785730451453,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":54,"time":1785730451453,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fbea6b64-84cf-4411-abec-48d26b3801da"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","seq":54,"time":1785730451453,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"eb51ecb3-3347-4216-ad4e-c2130c43ecfc"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1785730451454,"data":{"turn":1,"step":5}} {"type":"turn/end","seq":56,"time":1785730451454,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":57,"time":1786011346660,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"SECOND_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent 33333333-3333-4333-8333-333333333333 failed before it finished.","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"2cf0afd2-ee6e-4a3f-a35a-2fd4d5b665ca"}]}} +{"type":"turn/start","seq":58,"time":1786011346660,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":59,"time":1786011346660,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":60,"time":1786012466689,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":61,"time":1786012466689,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 failed before it finished."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"SECOND_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent 33333333-3333-4333-8333-333333333333 failed before it finished.","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"2cf0afd2-ee6e-4a3f-a35a-2fd4d5b665ca"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":62,"time":1786012466693,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":63,"time":1786012466693,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SUBAGENT_SETTLED_NOTED"}}} +{"type":"assistant/chunk","seq":64,"time":1786012466693,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"type":"assistant/chunk","seq":65,"time":1786012466693,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":66,"time":1786012466693,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":67,"time":1786333283620,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"758adcee-9284-4889-86a2-0181a278a754"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[62,63,64,65,66],"surfaceOp":"append"} +{"type":"step/end","seq":68,"time":1786333283620,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":69,"time":1786333283620,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl index 82ae8907ca..d6a2728b5a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl @@ -2,3 +2,4 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json index 6cecb69da2..d0b02de9dd 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json @@ -247,7 +247,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -261,7 +261,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index cfb7fee5a8..f36586cfb0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357533600,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} {"type":"step/start","seq":5,"time":1786357533602,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357533602,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"722c8476-461f-4a02-904a-f1084b973c95"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533602,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"78a8aa2f-27d5-45d1-8b06-7b2627fc76ae"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357533602,"data":{"title":"Call subagent once. Ask that","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 022584de40..e267d3ada1 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357533628,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} {"type":"step/start","seq":5,"time":1786357533630,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357533630,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"fc837f97-bbf9-4c44-9eea-3a8f52c7a77d"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533630,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"615d0b40-18da-47be-a235-7422989344f5"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357533630,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/input.json b/examples/acp-agent/tests/snapshots/subagent-list-agents/input.json index b3b1d8e170..ab0c00f2f2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/input.json +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/input.json @@ -14,6 +14,13 @@ "op": "waitForFile", "path": ".dsh-snapshot-subagent-settled" }, + { + "op": "waitForTurnStart", + "minimumTurn": 2 + }, + { + "op": "waitForTurnEnd" + }, { "op": "prompt", "text": "Call list_agents once with scope set to descendants and observe the subagent you started. Then call interrupt_agent once with agent_id set to 33333333-3333-4333-8333-333333333333. Then reply with the single word DONE. Do not call any other tool." diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl index 06bd463ba8..2cd0e7fc24 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl @@ -1,10 +1,10 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785730454756,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"c2febfff-792d-4457-a944-933ff0de0570"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785730454756,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"356b3b62-c8b8-4d2a-84d7-7df1b6e4811e"}]}} {"type":"turn/start","seq":1,"time":1785821412725,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821412725,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730454783,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785730454783,"data":{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"c2febfff-792d-4457-a944-933ff0de0570"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730454783,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"c9d1f853-56bc-4082-ae08-00d4bcbb04a6"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785730454783,"data":{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"356b3b62-c8b8-4d2a-84d7-7df1b6e4811e"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730454783,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"9be42fb0-f0d0-4ab9-a232-fb753f7db482"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730454783,"data":{"title":"Call the subagent tool once","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730454784,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730454784,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -13,9 +13,9 @@ {"type":"assistant/chunk","seq":11,"time":1785531795632,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} {"type":"assistant/chunk","seq":12,"time":1785531795632,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1785730454793,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730454793,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4dda49d6-5d02-456f-ba95-68699662793d"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730454793,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c8802574-4e43-4ee7-8648-5a132935b5dc"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730454793,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":16,"time":1785730454804,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"7f1fbe79-98b7-410a-af4d-c40d52a366dc"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730454804,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"c83395ad-93c6-4899-9ae1-8d29f92d4dde"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730454804,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730454814,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -23,39 +23,42 @@ {"type":"assistant/chunk","seq":21,"time":1785531795656,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}} {"type":"assistant/chunk","seq":22,"time":1785531795656,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1785730454820,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"time":1785730454820,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2ee459fa-21f9-48c6-a42e-3c38eca1e4c9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1785730454820,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1fefe87b-4c3c-49b0-860c-8097193f9567"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785730454821,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":26,"time":1785730454821,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"agent/inbox/spliced","seq":27,"time":1785730454857,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call list_agents once with scope set to descendants and observe the subagent you started. Then call interrupt_agent once with agent_id set to 33333333-3333-4333-8333-333333333333. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"3bcac2a3-e0db-465b-94b4-4e2761236475"}]}} -{"type":"turn/start","seq":28,"time":1785821412840,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":29,"time":1785821412840,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":30,"time":1785730454863,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":31,"time":1785730454863,"data":{"content":[{"type":"text","text":"Call list_agents once with scope set to descendants and observe the subagent you started. Then call interrupt_agent once with agent_id set to 33333333-3333-4333-8333-333333333333. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"3bcac2a3-e0db-465b-94b4-4e2761236475"},"surfaceOp":"append"} -{"type":"assistant/chunk","seq":32,"time":1785531795686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":33,"time":1785536135065,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list","name":"list_agents","argumentsDelta":"{\"scope\":\"descendants\"}"}}} -{"type":"assistant/chunk","seq":34,"time":1785730454867,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{\"scope\":\"descendants\"}"}}}} -{"type":"assistant/chunk","seq":35,"time":1785730454867,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":36,"time":1785730454867,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":37,"time":1785730454867,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{\"scope\":\"descendants\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"199c7794-d782-4957-b7ed-69d094b9c0ef"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} -{"type":"tool/call","seq":38,"time":1785730454867,"data":{"turn":2,"step":1,"callId":"call_list","name":"list_agents","arguments":"{\"scope\":\"descendants\"}"}} -{"type":"tool/result","seq":39,"time":1785730454893,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_list"},"content":[{"type":"tool-result","toolCallId":"call_list","content":[{"type":"text","text":"33333333-3333-4333-8333-333333333333 [complete] parent=11111111-1111-4111-8111-111111111111 depth=1 — Reply with CHILD_OK"}],"isError":false}],"role":"user","id":"61ceb650-3e32-413e-9d7e-b6ec8a351858"}},"sourceEventSeqs":[38],"surfaceOp":"append"} -{"type":"step/end","seq":40,"time":1785730454893,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":41,"time":1785730454903,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":42,"time":1785531795715,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":43,"time":1785536135106,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_interrupt","name":"interrupt_agent","argumentsDelta":"{\"agent_id\":\"33333333-3333-4333-8333-333333333333\"}"}}} -{"type":"assistant/chunk","seq":44,"time":1785730454907,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_interrupt","name":"interrupt_agent","arguments":"{\"agent_id\":\"33333333-3333-4333-8333-333333333333\"}"}}}} -{"type":"assistant/chunk","seq":45,"time":1785730454907,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":46,"time":1785730454907,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":47,"time":1785730454907,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_interrupt","name":"interrupt_agent","arguments":"{\"agent_id\":\"33333333-3333-4333-8333-333333333333\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"17c1a7c5-84f5-493e-adb5-6a65219e6ad6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} -{"type":"tool/call","seq":48,"time":1785730454907,"data":{"turn":2,"step":2,"callId":"call_interrupt","name":"interrupt_agent","arguments":"{\"agent_id\":\"33333333-3333-4333-8333-333333333333\"}"}} -{"type":"tool/result","seq":49,"time":1785730454917,"data":{"turn":2,"step":2,"message":{"source":{"kind":"tool","callId":"call_interrupt"},"content":[{"type":"tool-result","toolCallId":"call_interrupt","content":[{"type":"text","text":"interrupt requested for agent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"bf93cbe0-1946-4aef-a4ce-431790026c6f"}},"sourceEventSeqs":[48],"surfaceOp":"append"} -{"type":"step/end","seq":50,"time":1785730454917,"data":{"turn":2,"step":2}} -{"type":"step/start","seq":51,"time":1785730454927,"data":{"turn":2,"step":3}} -{"type":"assistant/chunk","seq":52,"time":1785531795715,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":53,"time":1785536135106,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":54,"time":1785730454907,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":55,"time":1785730454907,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":56,"time":1785730454907,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":57,"time":1785730454907,"data":{"turn":2,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e4c04cd6-9c23-4c99-a5f7-bac6fc141e95"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[52,53,54,55,56],"surfaceOp":"append"} -{"type":"step/end","seq":58,"time":1785730454938,"data":{"turn":2,"step":3}} -{"type":"turn/end","seq":59,"time":1785730454938,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":27,"time":1786011499211,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 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 33333333-3333-4333-8333-333333333333 finished and will do no further work unless you send it more.","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"9275a12c-bf9a-48e2-b33b-4fc484e936cb"}]}} +{"type":"turn/start","seq":28,"time":1786011499211,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":29,"time":1786011499211,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":30,"time":1786012470343,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":31,"time":1786012470343,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 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 33333333-3333-4333-8333-333333333333 finished and will do no further work unless you send it more.","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"9275a12c-bf9a-48e2-b33b-4fc484e936cb"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":32,"time":1786012470346,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":33,"time":1786011499224,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SUBAGENT_SETTLED_NOTED"}}} +{"type":"assistant/chunk","seq":34,"time":1786011499224,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"type":"assistant/chunk","seq":35,"time":1786011499224,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":36,"time":1786011499224,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1786012470346,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4620e8c0-dd13-4a2f-87dc-f4b66aa51219"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1786012470346,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":39,"time":1786012470346,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":40,"time":1786012470360,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call list_agents once with scope set to descendants and observe the subagent you started. Then call interrupt_agent once with agent_id set to 33333333-3333-4333-8333-333333333333. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"7a2a86d0-80a3-4db5-822f-2d3fcbc16e11"}]}} +{"type":"turn/start","seq":41,"time":1786011499224,"data":{"turn":3}} +{"type":"agent/inbox/spliced","seq":42,"time":1786011499224,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":43,"time":1786011499234,"data":{"turn":3,"step":1}} +{"type":"user/message","seq":44,"time":1786011499234,"data":{"content":[{"type":"text","text":"Call list_agents once with scope set to descendants and observe the subagent you started. Then call interrupt_agent once with agent_id set to 33333333-3333-4333-8333-333333333333. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"7a2a86d0-80a3-4db5-822f-2d3fcbc16e11"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":45,"time":1786011499238,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1786011499238,"data":{"turn":3,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list","name":"list_agents","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":47,"time":1785531795715,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":48,"time":1785536135106,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":49,"time":1785730454907,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1786011499238,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d3402e92-2f7e-4cd5-9537-ae9beedeecab"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1786011499238,"data":{"turn":3,"step":1,"callId":"call_list","name":"list_agents","arguments":"{}"}} +{"type":"tool/result","seq":52,"time":1786011499267,"data":{"turn":3,"step":1,"message":{"source":{"kind":"tool","callId":"call_list"},"content":[{"type":"tool-result","toolCallId":"call_list","content":[{"type":"text","text":"33333333-3333-4333-8333-333333333333 [complete] — Reply with CHILD_OK"}],"isError":false}],"role":"user","id":"8ae233de-8fde-48d7-a9d0-0d9a480a00d0"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1785730454908,"data":{"turn":3,"step":1}} +{"type":"step/start","seq":54,"time":1786011499275,"data":{"turn":3,"step":2}} +{"type":"assistant/chunk","seq":55,"time":1786011499279,"data":{"turn":3,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1786011499279,"data":{"turn":3,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":57,"time":1786011499279,"data":{"turn":3,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":58,"time":1786011499279,"data":{"turn":3,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":59,"time":1786011499279,"data":{"turn":3,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1786011499279,"data":{"turn":3,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3ac0f29f-72ae-44fb-9414-974470095618"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1786011499279,"data":{"turn":3,"step":2}} +{"type":"turn/end","seq":62,"time":1786011499279,"data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/stdout.expected.jsonl index 5a9b409384..4813c19f90 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/stdout.expected.jsonl @@ -2,5 +2,6 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"STARTED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json index 6cecb69da2..d0b02de9dd 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json @@ -247,7 +247,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -261,7 +261,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index 442c7f06c8..c41aad3d0c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357524752,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} {"type":"step/start","seq":5,"time":1786357524755,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357524755,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"36cca213-d6e9-491d-9442-87057994f573"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357524755,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"8b8f6910-a5fc-4839-837f-9b9335252807"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357524755,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index 6e071fa68d..9572fee7d9 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -27,7 +27,7 @@ {"type":"subagent/descriptor","seq":41,"time":1786357524800,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} {"type":"step/start","seq":42,"time":1786357524803,"data":{"turn":2,"step":1}} {"type":"user/message","seq":43,"time":1786357524803,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} -{"type":"user/message","seq":44,"time":1786358036899,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"86f2fe9d-3154-41c2-815e-e1c1e18ed895"},"surfaceOp":"append"} +{"type":"user/message","seq":44,"time":1786358036899,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"ccb25ca6-93e6-40e0-8713-ad065065d540"},"surfaceOp":"append"} {"type":"request/header","seq":45,"time":1786358036900,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","seq":46,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":47,"time0":1783352148077,"data":{"turn":2,"step":1,"index":0,"dt":[0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0,0,1790157964],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index e5848d377c..d6d7283abb 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357521754,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} {"type":"step/start","seq":5,"time":1786357521756,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357521756,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"c253c429-6ca2-4ac4-a192-ea1383e90a1e"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521756,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"e35564a9-35f0-4a5c-8754-e5502bc6a35b"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357521756,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index 95f756b06c..b01b69b45b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":1786357521799,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} {"type":"step/start","seq":5,"time":1786357521801,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1786357521802,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"488666ba-7318-4b83-bc3f-d7700024b196"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521802,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"56f96521-b5b9-4e1f-8ca4-38ac7c212d99"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":1786357521802,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl index edbef518f1..f8b5deef29 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl @@ -1,10 +1,10 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785730453561,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"e45e4b6d-dae7-42d9-9edb-d24385664025"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785730453561,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"b765ae32-73e2-4625-81ba-01095f8c83d0"}]}} {"type":"turn/start","seq":1,"time":1785821411429,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821411429,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730453591,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785730453591,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"e45e4b6d-dae7-42d9-9edb-d24385664025"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"9319509b-82df-4848-8f3a-7654804c9c45"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785730453591,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"b765ae32-73e2-4625-81ba-01095f8c83d0"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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":"1f4f5888-2068-4df0-904f-12ffb4aa3321"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730453592,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730453592,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730453593,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -13,9 +13,9 @@ {"type":"assistant/chunk","seq":11,"time":1785501592851,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}}}} {"type":"assistant/chunk","seq":12,"time":1785501592851,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1785730453601,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730453602,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"dcd1402a-35c7-4748-bfeb-8734e1018732"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730453602,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"97b897d5-0d01-4a6c-ad0c-4776c61c9c68"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730453602,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Report a finding\", \"prompt\": \"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":16,"time":1785730453613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"b752b617-5282-40b6-bfe0-0535fb069504"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730453613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"91fb94ce-cf3e-47ed-ab20-8f46cf4aec55"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730453613,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730453623,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -23,32 +23,35 @@ {"type":"assistant/chunk","seq":21,"time":1785501592877,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}} {"type":"assistant/chunk","seq":22,"time":1785501592877,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1785730453628,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"time":1785730453628,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d5bf8946-4dd4-464e-88de-c7d6191c7671"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1785730453628,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b1b1cf78-11a8-4440-b9f9-2096d15e7884"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785730453628,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":26,"time":1785730453629,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"agent/inbox/spliced","seq":27,"time":1786008565468,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"2ba9ef7b-dac7-4f39-8035-35f0bf88d0e7"}]}} -{"type":"turn/start","seq":28,"time":1786008565468,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":29,"time":1786008565468,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":30,"time":1786008565474,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":31,"time":1786008565474,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"2ba9ef7b-dac7-4f39-8035-35f0bf88d0e7"},"surfaceOp":"append"} -{"type":"assistant/chunk","seq":32,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":33,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"REPORT_RECEIVED"}}} -{"type":"assistant/chunk","seq":34,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"REPORT_RECEIVED"}}}} -{"type":"assistant/chunk","seq":35,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":36,"time":1786008565479,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":37,"time":1786008565479,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"REPORT_RECEIVED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0fe8d69e-6640-4bd5-b72b-70f2cf013cde"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} -{"type":"step/end","seq":38,"time":1786008565479,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":39,"time":1786008565479,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"agent/inbox/spliced","seq":40,"time":1786008565505,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"50d55072-bbf2-4e30-8948-1b837d8db9ee"}]}} -{"type":"turn/start","seq":41,"time":1786008565505,"data":{"turn":3}} -{"type":"agent/inbox/spliced","seq":42,"time":1786008565505,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":43,"time":1786008565511,"data":{"turn":3,"step":1}} -{"type":"user/message","seq":44,"time":1786008565511,"data":{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"50d55072-bbf2-4e30-8948-1b837d8db9ee"},"surfaceOp":"append"} -{"type":"assistant/chunk","seq":45,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":46,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_REPORT_OK"}}} -{"type":"assistant/chunk","seq":47,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_REPORT_OK"}}}} -{"type":"assistant/chunk","seq":48,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":49,"time":1786008565516,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":50,"time":1786008565516,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de2ca55e-a346-4f25-911d-a33316b9866b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} -{"type":"step/end","seq":51,"time":1786008565516,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":52,"time":1786008565516,"data":{"turn":3,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":27,"time":1786008565468,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"ec024a7a-5506-4ebf-a9d8-82ce01dc88b4"}]}} +{"type":"agent/inbox/spliced","seq":28,"time":1786012334062,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"Reported."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent 33333333-3333-4333-8333-333333333333 finished and will do no further work unless you send it more.","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"08101cc1-abde-49ca-9745-1d075a3911b5"}]}} +{"type":"turn/start","seq":29,"time":1786012334062,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":30,"time":1786012334062,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","seq":31,"time":1786012334062,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":32,"time":1786376144953,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":33,"time":1786376144953,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 reported:"},{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"subagent-report","form":"relay","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"ec024a7a-5506-4ebf-a9d8-82ce01dc88b4"},"surfaceOp":"append"} +{"type":"user/message","seq":34,"time":1786012334068,"data":{"content":[{"type":"text","text":"Background subagent 33333333-3333-4333-8333-333333333333 finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"Reported."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent 33333333-3333-4333-8333-333333333333 finished and will do no further work unless you send it more.","senderSessionId":"33333333-3333-4333-8333-333333333333"},"role":"user","id":"08101cc1-abde-49ca-9745-1d075a3911b5"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":35,"time":1786376144959,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":36,"time":1786012334073,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SUBAGENT_SETTLED_NOTED"}}} +{"type":"assistant/chunk","seq":37,"time":1786012334073,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"type":"assistant/chunk","seq":38,"time":1786012334073,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":39,"time":1786012334073,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":40,"time":1786376144959,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c6fecd6e-033f-4be8-98ee-cc0733b18c83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"step/end","seq":41,"time":1786376144960,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":42,"time":1786376144960,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":43,"time":1786376145165,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"6c0b0e51-4ad9-4c4b-bbbc-508973862b77"}]}} +{"type":"turn/start","seq":44,"time":1786012334073,"data":{"turn":3}} +{"type":"agent/inbox/spliced","seq":45,"time":1786012334073,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":46,"time":1786012334082,"data":{"turn":3,"step":1}} +{"type":"user/message","seq":47,"time":1786012334083,"data":{"content":[{"type":"text","text":"Repeat back, verbatim, the exact output the background subagent reported to you. Reply with only that text. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"6c0b0e51-4ad9-4c4b-bbbc-508973862b77"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":48,"time":1786012334087,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":49,"time":1786012334087,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_REPORT_OK"}}} +{"type":"assistant/chunk","seq":50,"time":1786012334087,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_REPORT_OK"}}}} +{"type":"assistant/chunk","seq":51,"time":1786012334087,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":52,"time":1786012334087,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":53,"time":1786012334087,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_REPORT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"98d44266-6695-482b-910c-0e1e570fe7a6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"step/end","seq":54,"time":1786012334087,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":55,"time":1786012334087,"data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl index cd63d489c7..785ee28128 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl @@ -2,6 +2,6 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"STARTED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"REPORT_RECEIVED"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CHILD_REPORT_OK"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json index 6cecb69da2..d0b02de9dd 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json @@ -247,7 +247,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -261,7 +261,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 4b7d68131f..adee651183 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -231,7 +231,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -245,7 +245,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index 9f198a86a5..5050d37d16 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -231,7 +231,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -245,7 +245,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message." } }, "required": [ diff --git a/examples/headless-agent/subagent-settlement.cordis.snapshot.yml b/examples/headless-agent/subagent-settlement.cordis.snapshot.yml new file mode 100644 index 0000000000..4812d676a2 --- /dev/null +++ b/examples/headless-agent/subagent-settlement.cordis.snapshot.yml @@ -0,0 +1,21 @@ +# Keyless assembled-app coverage for continuable child settlement delivery. The +# replay child deliberately never calls report; the parent can reach its final +# answer only if the continuation manager places the child's closing message in +# the parent turn without list_agents, send_message, or a Task collector. + +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + +# Prevent platform scheduling from choosing a streamed-chunk interleave. The +# fence releases only after the real manager notice enters the parent inbox. +- id: settlement-fence + name: './tests/fixtures/subagent-settlement-fence.ts' diff --git a/examples/headless-agent/tests/fixtures/subagent-settlement-fence.ts b/examples/headless-agent/tests/fixtures/subagent-settlement-fence.ts new file mode 100644 index 0000000000..c2c8a87885 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/subagent-settlement-fence.ts @@ -0,0 +1,43 @@ +/** + * Loader fixture that holds the parent's second step until settlement delivery. + * @module subagent-settlement-fence + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent-loop' +import type {} from '@deepseek-ai/dsh-subagent' + +/** Fixture plugin name. */ +export const name = 'subagent-settlement-fence' + +/** + * Fence the parent's post-spawn request behind admission of the manager notice. + * + * This pins content order, not step placement: the held pre-step runs after its + * own `Inbox.claim()`, so the notice lands after step 2's claim and is claimed at + * step 3 because the child's settlement pipeline is strictly longer than the + * parent's claim path, not because a barrier forces it. + * @param ctx - assembled headless-agent context. + */ +export function apply(ctx: Context): void { + const delivered = Promise.withResolvers() + let hasDelivered = false + + ctx.effect(() => { + const disposeInbox = ctx.root.on('agent/inbox/inserted', ({ agent, message }) => { + if (agent.session.header.parentSession !== undefined || message.source.kind !== 'subagent-settled') return + hasDelivered = true + delivered.resolve(undefined) + }) + const disposeStep = ctx.root.on('agent/pre-step', async ({ agent, turn, step }, next) => { + if (agent.session.header.parentSession === undefined && turn === 1 && step === 2 && !hasDelivered) { + await delivered.promise + } + return next() + }) + return () => { + disposeStep() + disposeInbox() + } + }, 'subagent-settlement-fence.listeners') +} diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 5fc337527e..61c1711566 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -45,6 +45,8 @@ const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snaps const invalidCredentialScenarioDir = join(snapshotsDir, 'invalid-credential') const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) +const settlementScenarioDir = join(snapshotsDir, 'subagent-settlement') +const settlementConfigPath = fileURLToPath(new URL('../subagent-settlement.cordis.snapshot.yml', import.meta.url)) const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) @@ -777,6 +779,77 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('delivers a continuable child result without parent polling', async () => { + const parentReplay = join(settlementScenarioDir, 'parent.replay.jsonl') + const parentOverride = join(settlementScenarioDir, 'parent.override.json') + const childReplay = join(settlementScenarioDir, 'child.replay.jsonl') + const childExpected = join(settlementScenarioDir, 'child.expected.jsonl') + const streamExpected = join(settlementScenarioDir, 'stream-json.expected.jsonl') + const task = 'Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, task_output, or task_list.' + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'continuable settlement headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-subagent-settlement-', + binScript, + libBinScript: binScript, + configPath: settlementConfigPath, + binArgs: [settlementConfigPath, task], + tsconfigPath, + env: { + // The override fully supplies the parent script; the child fixture + // remains separate so replay binds it to the fresh child Session. + DSH_SNAPSHOT_FILE: parentReplay, + DSH_SNAPSHOT_OVERRIDE: parentOverride, + DSH_SNAPSHOT_CHILD_FILES: childReplay, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(2) + const parent = logs.find(log => typeof log.header.parentSession !== 'string') + const child = logs.find(log => typeof log.header.parentSession === 'string') + if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log') + + const parentRecords = parseJsonl(parent.content) + const calls = parentRecords.filter(record => record.type === 'tool/call') + expect(calls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['subagent']) + const callArguments = (calls[0]?.data as JsonObject | undefined)?.arguments + if (typeof callArguments !== 'string') throw new Error('subagent call did not persist its arguments') + expect(JSON.parse(callArguments)).toMatchObject({ run_in_background: true }) + + const notices = parentRecords.flatMap((record) => { + if (record.type !== 'agent/inbox/spliced') return [] + const inserted = (record.data as JsonObject | undefined)?.inserted + if (!Array.isArray(inserted)) return [] + return (inserted as JsonObject[]).filter((message) => { + const source = message.source as JsonObject | undefined + return source?.kind === 'subagent-settled' + }) + }) + expect(notices).toHaveLength(1) + expect(JSON.stringify(notices[0])).toContain('CHILD_RESULT') + + const context = contextFromLogs([parent.content, child.content]) + const normalizedChild = scrubRequestHeaders(normalizeSessionLog(child.content, context)) + if (refreshing) await writeFile(childExpected, normalizedChild) + expect(normalizedChild).toBe(await readFile(childExpected, 'utf8')) + expect(normalizedChild).toContain('CHILD_RESULT') + expect(normalizedChild).not.toContain('"name":"report"') + }, + }) + + expect(result.stderr).toBe('') + const records = parseJsonl(result.stdout) + expect(records.at(-1)).toMatchObject({ + type: 'result', + output: 'PARENT_RECEIVED_CHILD_RESULT', + }) + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays persistent PTY tools through the one-shot app', async () => { const input = JSON.parse(await readFile(join(ptyScenarioDir, 'input.json'), 'utf8')) as { steps?: { op?: unknown; text?: unknown }[] diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index acbf5f8cc9..ed147a24e6 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,19 +1,19 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"48a309a8-4004-43e9-87ea-0bd02e9f5857"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"debf537f-cdc4-4b03-aa58-bed881573aab"}]}} {"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"48a309a8-4004-43e9-87ea-0bd02e9f5857"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786358103673,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"b8be7a23-617a-4aad-93e6-d12f0593f71d"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1786358103673,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"debf537f-cdc4-4b03-aa58-bed881573aab"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786373992181,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"78fcf31b-a20e-49aa-a558-e468c776e1f4"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786373992181,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","seq":13,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9f582f43-25e4-4f21-a81c-0186f42fadf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","seq":15,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e8fa01a4-826b-4b6d-b342-39d76e8643cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501507,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":17,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index c5773c21aa..dc4340c8a6 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,19 +1,19 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"11d0804b-a6bb-4dcc-be05-6f35f81f4f13"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"0e1bad92-814b-454d-93d9-843b938e7486"}]}} {"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"11d0804b-a6bb-4dcc-be05-6f35f81f4f13"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786358103827,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"0d36194f-6afa-41a4-907e-c9e0583b797a"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1786358103827,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"0e1bad92-814b-454d-93d9-843b938e7486"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786373992411,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"56005931-a16c-412c-b184-8a5a24b2a799"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786373992411,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","seq":13,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"eb7781b5-d2ac-4a94-8e21-c9f769524a56"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","seq":15,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"090217d2-8555-4c5d-bad2-7a6f24e37f3f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501646,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":17,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index a3faa19c4b..3c2c4aaf35 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"342d68c5-9a35-46dc-9d80-d18a2375a0cf"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"a690e220-0f80-4410-9622-80a43791c4c2"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"342d68c5-9a35-46dc-9d80-d18a2375a0cf"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"a690e220-0f80-4410-9622-80a43791c4c2"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0933e3da-0073-49c2-af4a-76c95ea72f02"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f0df95b4-63ae-4fdd-83b1-6d10b7fb831e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"716e9e93-6560-4f6d-ad52-0cb8e833da39"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"a9e79dc0-3f12-4bf2-a287-e0a3a73f9c28"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a6cdb2d-c83c-4b7c-af56-2bf3fbece4ae"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4aff202c-1298-49be-a8b0-3d53fc105e71"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"045ef2ea-7cfb-4a43-a6ef-d4ef7ad979e1"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"bbbb0d25-2353-4bdb-a329-5846bd82fa9d"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"26ff28fa-fab8-4e92-a72f-d3a5b3352573"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f481573d-7672-42e5-aca0-621eb19f0740"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"e33536a0-0801-4f51-a633-baac1167cfee"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"f83932ec-a8ed-4f40-957c-98c2fe467e93"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,9 +44,9 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"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-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"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-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"492fd6ab-4937-4083-a8e2-395d5d254c3e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"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-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"452b40e7-0d9d-4611-aa86-6c03c05f4420"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"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-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"31627ac4-1b5d-4213-814c-95599db9a215"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"b7785180-8d83-47a3-937c-307b8725662d"}},"sourceEventSeqs":[46],"surfaceOp":"append"} {"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} {"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -54,9 +54,9 @@ {"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0b2fa8a0-7559-4fcf-a34d-bb190f8d9f48"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} +{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9d62978e-ba44-465f-8fb0-f08e55de8437"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} {"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"cfc46278-d37c-4ced-8763-200e37d3290f"}},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"a771d283-634b-4c0f-98a0-83b6e98b9966"}},"sourceEventSeqs":[56],"surfaceOp":"append"} {"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} {"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -64,6 +64,6 @@ {"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4c1bdf08-4721-4c52-9f75-56b2c2028c7d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3b822282-edac-4abb-9a4c-e91e5724764f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl index 5c68474179..0ab6d3f13e 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -1,32 +1,32 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"c5cc1fd3-c9d5-4b49-90b0-34da051cb71e"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"ab1f37e8-5e1b-4b15-a980-17ee9449b35d"}]}} {"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"c5cc1fd3-c9d5-4b49-90b0-34da051cb71e"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"ab1f37e8-5e1b-4b15-a980-17ee9449b35d"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e29c0ce2-2826-4269-a047-a831aec5a4ee"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2cf46d07-dbd9-4aad-ba5e-5954eb1a7ea4"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"8c3b43ac-1a3b-4955-ad40-78a1d6343e2d"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"a1c85dd0-920f-40f5-9c61-b33bc255517d"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} {"type":"compact/start","seq":19,"time":1786123401715,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"compact/summary","seq":20,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"bb3fd14b-4307-4550-9aa9-3326c9ebd92f"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"1541cd3a-8b31-45d2-a44e-8034d844cd8e"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} {"type":"compact/end","seq":22,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3712f33d-6371-454f-bf8e-707cb5c74b32"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3ac58347-7e9a-442e-84aa-734c071142c2"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index bfd47888f9..2fd78968ad 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,21 +1,21 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d4746327-c682-4874-b8b2-bbc81e98ea03"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"cb953265-fb22-488e-91cb-e7f250313654"}]}} {"type":"turn/start","seq":1,"time":1785821457966,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821457966,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d4746327-c682-4874-b8b2-bbc81e98ea03"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730504659,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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."}]},"role":"user","id":"8256fc51-472b-4d50-86ea-b50cef7b1ef6"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"cb953265-fb22-488e-91cb-e7f250313654"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730504659,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","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."}]},"role":"user","id":"cebb0669-2ea4-46dc-988d-540ff52c6ae6"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730504659,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498587438,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498587438,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"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. 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.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","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":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. 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":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730504660,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} {"type":"assistant/chunk","seq":12,"time":1785498587439,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1785730504661,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4f71f30a-33e5-4c25-8e66-aa93c993a7e8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ba2d7ce-8feb-410c-a223-c45374a7e203"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730504662,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"e5c2f208-b939-4715-984e-a4537effbbff"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"d5d25a8d-2980-4b77-99d4-72883cc36f6e"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730504671,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730504679,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} {"type":"assistant/chunk","seq":22,"time":1785498587457,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1785730504680,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b1af47f1-f976-41fc-b6f1-8ddc86f2aecd"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"022de086-1aa9-4c05-89f5-a56c63724fd6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785730504680,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"82baaf37-cfd3-4c59-9697-7a1b36fffa38"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"b1260dd8-eff9-433c-96cb-9604b1b6a64e"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":27,"time":1785730504688,"data":{"turn":1,"step":2}} {"type":"step/start","seq":28,"time":1785730504696,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} {"type":"assistant/chunk","seq":32,"time":1785498587473,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":33,"time":1785730504697,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3de44e0c-f4b8-493e-842b-6aaa029569d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ce06d9e5-07cf-4d32-a085-5587038d7a92"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","seq":35,"time":1785730504697,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"178561b8-fa84-4dc0-9bc8-d183a722d36a"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"8249c9b0-d783-4a5a-b60c-a4fe35a66526"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","seq":37,"time":1785730504704,"data":{"turn":1,"step":3}} {"type":"step/start","seq":38,"time":1785730504712,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} {"type":"assistant/chunk","seq":42,"time":1785498587489,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":43,"time":1785730504713,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fc614320-c742-470b-a9fc-902fd67d5cb4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6bc7d9c0-20d3-4556-aa55-8d2f43f004a4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","seq":45,"time":1785730504713,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"3f047431-06fc-4201-b4cd-b611b6741ce3"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"cd54f0b1-11a2-4b1c-b530-ba0d0909f4d8"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","seq":47,"time":1785730504721,"data":{"turn":1,"step":4}} {"type":"step/start","seq":48,"time":1785730504730,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,9 +53,9 @@ {"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}} {"type":"assistant/chunk","seq":52,"time":1785498587503,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":53,"time":1785730504731,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1a4e37b3-72fe-4cbe-bbdb-708088894423"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"90745bdd-9fc0-4e28-bc1a-187340f1ed40"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","seq":55,"time":1785730504731,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"a04c5ae2-3b36-40d0-b570-df158f27d08a"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"15f8d343-4410-4fdf-a9ef-c2b71db3cc6c"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1785730504738,"data":{"turn":1,"step":5}} {"type":"step/start","seq":58,"time":1785730504746,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -63,9 +63,9 @@ {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}} {"type":"assistant/chunk","seq":62,"time":1785498587517,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":63,"time":1785730504747,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0656b271-2099-44fe-a770-834bb6c97e32"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c1050cd4-0f07-43c5-bd50-4edf9341a9ff"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","seq":65,"time":1785730504747,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"8b6397b3-9ee8-431e-975a-200b14670d7e"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"6afd008d-3796-4358-a6bb-4dac091768e1"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","seq":67,"time":1785730504755,"data":{"turn":1,"step":6}} {"type":"step/start","seq":68,"time":1785730504763,"data":{"turn":1,"step":7}} {"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -73,6 +73,6 @@ {"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":72,"time":1785498587531,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} {"type":"assistant/chunk","seq":73,"time":1785730504764,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"74b620d5-2737-4b35-b9c9-2829fe59bea5"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"be896da6-8db2-4277-bdf8-063cbc0150aa"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} {"type":"step/end","seq":75,"time":1785730504764,"data":{"turn":1,"step":7}} {"type":"turn/end","seq":76,"time":1785730504764,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl b/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl new file mode 100644 index 0000000000..9d63ab152c --- /dev/null +++ b/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl @@ -0,0 +1,20 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","origin":"subagent","delegationDepth":1} +{"type":"subagent/descriptor","seq":0,"time":0,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"session/end-seed","seq":1,"time":0,"data":{}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call report."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":3,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":5,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call report."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":0,"data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_RESULT"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_RESULT"}}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/subagent-settlement/child.replay.jsonl b/examples/headless-agent/tests/snapshots/subagent-settlement/child.replay.jsonl new file mode 100644 index 0000000000..0f7362448a --- /dev/null +++ b/examples/headless-agent/tests/snapshots/subagent-settlement/child.replay.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":0,"id":"subagent-settlement-child","createdAt":2,"delegationDepth":1} +{"type":"assistant/chunk","seq":0,"time":1,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":1,"time":2,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_RESULT"}}} +{"type":"assistant/chunk","seq":2,"time":3,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_RESULT"}}}} +{"type":"assistant/chunk","seq":3,"time":4,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":4,"time":5,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/examples/headless-agent/tests/snapshots/subagent-settlement/parent.override.json b/examples/headless-agent/tests/snapshots/subagent-settlement/parent.override.json new file mode 100644 index 0000000000..bdb77f505e --- /dev/null +++ b/examples/headless-agent/tests/snapshots/subagent-settlement/parent.override.json @@ -0,0 +1,32 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "start-child", "name": "subagent", "argumentsDelta": "{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call report.\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "start-child", "name": "subagent", "arguments": "{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call report.\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "STARTED" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "STARTED" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "PARENT_RECEIVED_CHILD_RESULT" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "PARENT_RECEIVED_CHILD_RESULT" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/headless-agent/tests/snapshots/subagent-settlement/parent.replay.jsonl b/examples/headless-agent/tests/snapshots/subagent-settlement/parent.replay.jsonl new file mode 100644 index 0000000000..b4216aa0ed --- /dev/null +++ b/examples/headless-agent/tests/snapshots/subagent-settlement/parent.replay.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"subagent-settlement-parent","createdAt":1,"delegationDepth":0} diff --git a/examples/headless-agent/tests/snapshots/subagent-settlement/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/subagent-settlement/stream-json.expected.jsonl new file mode 100644 index 0000000000..bc181b78ad --- /dev/null +++ b/examples/headless-agent/tests/snapshots/subagent-settlement/stream-json.expected.jsonl @@ -0,0 +1,38 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, task_output, or task_list."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, task_output, or task_list."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Start one continuable background subagen","messageSeqs":[4],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"start-child","name":"subagent","argumentsDelta":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call report.\",\"run_in_background\":true}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call report.\",\"run_in_background\":true}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call report.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call report.\",\"run_in_background\":true}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","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 and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"STARTED"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":26,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":28,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"PARENT_RECEIVED_CHILD_RESULT"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":36,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","sessionId":"{{sessionId}}","output":"PARENT_RECEIVED_CHILD_RESULT","usage":{"inputTokens":30,"outputTokens":15}} diff --git a/knip.json b/knip.json index 2f755dbb5f..a721608a76 100644 --- a/knip.json +++ b/knip.json @@ -38,6 +38,7 @@ "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", "headless-agent/tests/fixtures/subagent-diagnostic-agent.ts", "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", + "headless-agent/tests/fixtures/subagent-settlement-fence.ts", "headless-agent/tests/fixtures/workspace-context-resume-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", @@ -50,6 +51,7 @@ "acp-agent/tests/fixtures/parent-sandbox-override.ts", "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", + "acp-agent/tests/fixtures/subagent-report-fence.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/workspace-context-compaction.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 477c3a5cb6..8022432bc1 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 0fb65b94a3b311aa9f0df09d39dd937cba4cc7b4 -README.zh.md: 44f67483343a98c280317793ece544bd0b984596 +README.md: 61294df5cca1b03f9f158678f2992a6e4fbaaffd +README.zh.md: 5576a07330926cfd8fd5bd42c8a0351a375f035c diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 0fb65b94a3..61294df5cc 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -58,6 +58,8 @@ Inbox live notifications are deliberately per-message and minimal: `agent/inbox/ Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). +`foldConsumedWork(events)` reads that feed back for the one question the turn sequence cannot answer alone: what became of the work a log consumed. It returns the latest `turn/end` that accounts for consumed work — a turn that entered a model step, or one that claimed inbox input and then failed, was stopped, or was rejected before reaching one — plus whether accepted work was later cancelled out of the inbox unrun. Both facts come from the log, so a cancellation reads the same whichever owner issued it. A no-step turn that took nothing, or emptied its claim and completed, describes no work and is skipped; a `blocked` end over claimed input is an account, because rejection discarded that input. + ### Agent interface (`types.ts`) The handle every plugin programs against: diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 44f6748334..5576a07330 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -58,6 +58,8 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte 轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。 +`foldConsumedWork(events)` 把这条事件流读回来,回答仅凭轮次序列无法回答的那个问题:一份日志消费掉的工作最终怎样了。它返回能够为已消费工作作出交代的最新 `turn/end`——即进入过模型 step 的轮次,或者认领了 inbox 输入、但在进入 step 之前失败、被停下或被拒绝的轮次——并额外给出「已接受的工作此后是否被从 inbox 中取消且从未运行」。两项事实都来自日志,因此无论由哪个所有者发起取消,读出来都一样。没有取走任何输入、或认领批次被改写清空后正常结束的无 step 轮次不描述工作,会被跳过;认领过输入、以 `blocked` 结束的轮次则是一份交代,因为拒绝把这些输入一并丢弃了。 + ### Agent 接口(`types.ts`) 每个插件面向的 handle: diff --git a/packages/core/agent/src/consumed-work.ts b/packages/core/agent/src/consumed-work.ts new file mode 100644 index 0000000000..b86ac836ef --- /dev/null +++ b/packages/core/agent/src/consumed-work.ts @@ -0,0 +1,108 @@ +/** + * How one agent log accounts for the work it consumed. + * + * The turn and step vocabulary alone cannot answer this. A turn that stops + * before its first step leaves a `turn/end` shaped exactly like the balanced + * no-op turns a rejection or an empty claim produces, so reading turns in + * isolation either credits cut-short work as finished or convicts every no-op. + * The missing fact is the inbox's own record: {@link Inbox} logs each mutation + * with `removedCount` and marks a cancellation `outcome: 'canceled'`, which + * separates a turn claiming its input from work being dropped unrun. + * + * @module @deepseek-ai/dsh-agent/consumed-work + */ + +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' + +/** How one agent log accounts for the work it consumed. */ +export interface ConsumedWork { + /** + * The latest closed turn that accounts for consumed work: one that entered a + * model step, or one that claimed inbox input and then failed, was stopped, + * or was rejected. Absent when no turn closed over any work. + */ + readonly end?: SessionEvent<'turn/end'> + /** + * Whether accepted work was cancelled out of the inbox, unrun, after that + * turn. This is the only account of input a cancellation took before any turn + * could open over it — no `turn/end` describes it. + */ + readonly droppedUnrun: boolean +} + +/** + * Whether a turn that consumed input but never reached a step ends in a way + * that accounts for that input. Only a `completed` end does not: it had + * nothing left to run once its claim was rewritten away. A `blocked` end is + * that input's ending too — the pre-step rejection that produced it discarded + * the claimed messages, so the work it took will never run. + * @param reason - the turn's recorded ending. + * @returns whether the ending accounts for the input the turn took. + */ +function accountsForClaim(reason: TurnEndReason): boolean { + switch (reason.kind) { + case 'completed': + return false + case 'blocked': + case 'aborted': + case 'interrupted': + case 'error': + return true + /* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step, + * so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is + * merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over + * consumed input must not read as success. */ + default: + return true + } +} + +/** + * Fold one agent log, or an owned suffix of one, into its account of consumed + * work. Single pass, and every input is the log itself: no caller has to sample + * live state before cancelling, so a cancellation issued by anyone — the owner's + * teardown, an ancestor's interrupt, an unloading plugin — reads the same. + * @param events - the log, or an owned suffix, to fold. + * @returns the accounting turn when one closed, and whether work was dropped unrun after it. + */ +export function foldConsumedWork(events: readonly SessionEvent[]): ConsumedWork { + const stepped = new Set() + const claimed = new Set() + let open: number | undefined + let end: SessionEvent<'turn/end'> | undefined + let droppedUnrun = false + for (const event of events) { + switch (event.type) { + case 'turn/start': + open = event.data.turn + break + case 'step/start': + stepped.add(event.data.turn) + break + case 'agent/inbox/spliced': { + const { removedCount, outcome, inserted } = event.data + if (removedCount === undefined) break + // A replacement keeps the work pending under a new identity, so only a + // cancellation that leaves nothing behind drops it. + if (outcome === 'canceled') droppedUnrun ||= inserted.length === 0 + // Claims are the loop's own step-boundary reads, always inside a turn. + else if (open !== undefined) claimed.add(open) + break + } + case 'turn/end': { + const { turn, reason } = event.data + open = undefined + if (stepped.delete(turn) || (claimed.delete(turn) && accountsForClaim(reason))) { + end = event + // Anything dropped before this turn closed is what its own ending + // reports; only a later drop is still unaccounted for. + droppedUnrun = false + } + break + } + default: + break + } + } + return { ...end === undefined ? {} : { end }, droppedUnrun } +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index dffc1f01b3..5211942f11 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -18,6 +18,7 @@ import type { Agent, AgentOptions } from './runtime-types.ts' export * from './runtime-types.ts' export * from './types.ts' export * from './inbox.ts' +export * from './consumed-work.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/tests/consumed-work.spec.ts b/packages/core/agent/tests/consumed-work.spec.ts new file mode 100644 index 0000000000..3b79d67620 --- /dev/null +++ b/packages/core/agent/tests/consumed-work.spec.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { TurnEndReason } from '@deepseek-ai/dsh-session' +import { foldConsumedWork } from '@deepseek-ai/dsh-agent' + +/** One pending message, as the inbox records it. */ +function message(text: string) { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) +} + +/** Log an accepted message the way `Inbox.append()` does. */ +function accept(session: Session, text: string): void { + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message(text)] }) +} + +/** Log the step-boundary read of one pending message, as `Inbox.claim()` does. */ +function claim(session: Session): void { + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) +} + +/** Log a cancellation of one pending message, as `Inbox.clear()` does. */ +function cancelPending(session: Session): void { + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled', + }) +} + +/** Run one whole turn that reached a model step. */ +function steppedTurn(session: Session, turn: number, reason: TurnEndReason): void { + session.append('turn/start', { turn }) + claim(session) + session.append('step/start', { turn, step: 1 }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason }) +} + +describe('foldConsumedWork', () => { + it('reports nothing for a log that consumed no work', () => { + const session = Session.create(SessionId('empty')) + accept(session, 'queued') + + expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false }) + }) + + it('reports the latest turn that entered a model step', () => { + const session = Session.create(SessionId('stepped')) + steppedTurn(session, 1, { kind: 'completed' }) + steppedTurn(session, 2, { kind: 'max-tokens' }) + + expect(foldConsumedWork(session.events).end?.data) + .toEqual({ turn: 2, reason: { kind: 'max-tokens' } }) + }) + + it('reports a turn that claimed its input and then failed before any step', () => { + const session = Session.create(SessionId('failed-claim')) + steppedTurn(session, 1, { kind: 'completed' }) + // The step boundary runs the durability checkpoint and prompt assembly, so a + // turn can take its input and then fail without entering a step. + session.append('turn/start', { turn: 2 }) + claim(session) + session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } }) + + expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + }) + + it('reports a turn that claimed its input and was then stopped before any step', () => { + const session = Session.create(SessionId('stopped-claim')) + steppedTurn(session, 1, { kind: 'completed' }) + session.append('turn/start', { turn: 2 }) + claim(session) + session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) + + expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + }) + + it('ignores a turn stopped, failed, or rejected without taking any input', () => { + const session = Session.create(SessionId('no-claim')) + steppedTurn(session, 1, { kind: 'completed' }) + session.append('turn/start', { turn: 2 }) + session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'parent' } } }) + session.append('turn/start', { turn: 3 }) + session.append('turn/end', { turn: 3, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } }) + session.append('turn/start', { turn: 4 }) + session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } }) + + // None of these turns describes work: they opened, found nothing of their own, and closed. + expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + }) + + it('reports a turn whose claimed input a pre-step rejection discarded', () => { + const session = Session.create(SessionId('rejected-claim')) + steppedTurn(session, 1, { kind: 'completed' }) + session.append('turn/start', { turn: 2 }) + claim(session) + session.append('turn/end', { turn: 2, reason: { kind: 'blocked' } }) + + // Rejection does not retain the claimed messages, so the `blocked` end is + // the only account of input that will never run. + expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + }) + + it('ignores a claim its own turn emptied', () => { + const session = Session.create(SessionId('emptied-claim')) + steppedTurn(session, 1, { kind: 'completed' }) + session.append('turn/start', { turn: 2 }) + claim(session) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + + // An emptied claim ran nothing and dropped nothing: a listener rewrote the + // batch away, which is not this log's account of the work. + expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + }) + + it('credits a claim with no open turn to no turn at all', () => { + const session = Session.create(SessionId('mid-turn-suffix')) + steppedTurn(session, 1, { kind: 'completed' }) + // An owned suffix can begin inside a turn whose start it does not contain, + // so a claim may appear with no turn to attribute it to. + claim(session) + session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) + + expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + }) + + it('reports work cancelled out of the inbox after the last accounting turn', () => { + const session = Session.create(SessionId('dropped')) + steppedTurn(session, 1, { kind: 'completed' }) + accept(session, 'never runs') + cancelPending(session) + + // No turn opened over it, so only the cancellation says the work was cut short. + expect(foldConsumedWork(session.events)).toEqual({ + end: session.events.find(event => event.type === 'turn/end'), + droppedUnrun: true, + }) + }) + + it('keeps a replacement pending rather than counting it as dropped', () => { + const session = Session.create(SessionId('replaced')) + steppedTurn(session, 1, { kind: 'completed' }) + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled', + }) + + expect(foldConsumedWork(session.events).droppedUnrun).toBe(false) + }) + + it('lets a later accounting turn absorb an earlier drop', () => { + const session = Session.create(SessionId('absorbed')) + steppedTurn(session, 1, { kind: 'completed' }) + cancelPending(session) + steppedTurn(session, 2, { kind: 'completed' }) + + expect(foldConsumedWork(session.events)).toEqual({ + end: session.events.findLast(event => event.type === 'turn/end'), + droppedUnrun: false, + }) + }) +}) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5251ca9408..477d08ae19 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -34,27 +34,6 @@ export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSur export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' -/** - * Find the latest closed turn that entered at least one model step, ignoring - * balanced no-step turns produced by rejection, empty input, or cancellation. - * @param events - session events, or an owned suffix, to inspect. - * @returns the latest matching turn end, or `undefined`. - */ -export function findLastMessageTurnEnd( - events: readonly SessionEvent[], -): SessionEvent<'turn/end'> | undefined { - const steppedTurns = new Set() - let latest: SessionEvent<'turn/end'> | undefined - for (const event of events) { - if (event.type === 'step/start') { - steppedTurns.add(event.data.turn) - continue - } - if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event - } - return latest -} - declare module '@deepseek-ai/cordis' { interface Context { sessions: SessionStore diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 7fe7ee01c7..d29624c030 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -7,28 +7,11 @@ import SessionStore, { Session, SessionEvent, SessionId, - findLastMessageTurnEnd, snapshotSessionEvent, } from '@deepseek-ai/dsh-session' import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session' describe('Session', () => { - it('finds the latest closed turn that entered a model step', () => { - const session = Session.create(SessionId('last-message-turn')) - session.append('turn/start', { turn: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } }) - - expect(findLastMessageTurnEnd(session.events)).toBeUndefined() - - session.append('turn/start', { turn: 2 }) - session.append('step/start', { turn: 2, step: 1 }) - session.append('step/end', { turn: 2, step: 1 }) - session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } }) - - expect(findLastMessageTurnEnd(session.events)?.data) - .toEqual({ turn: 2, reason: { kind: 'max-tokens' } }) - }) - it('exposes one stable readonly surface view', () => { const session = Session.create(SessionId('surface-view')) const surface = session.surface diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index b0ab7425a5..0e7942fa55 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -13,8 +13,9 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' +import { foldConsumedWork } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' -import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { appendDelegatedPolicyOverrides, @@ -52,6 +53,10 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { 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: @@ -207,7 +212,11 @@ function readResult( structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { const own = child.session.events.slice(boundary) - const lastEnd = findLastMessageTurnEnd(own) + // `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) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 4b9041ebf9..bc25a79c23 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -82,6 +82,20 @@ describe('startInProcessRun', () => { 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 diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index c70bcdf58b..d6284bd1d8 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: 28f649ef54bbf88feda24a9ce197c2c366f8349b -README.zh.md: 595c5e5e7fffc367f2e3fd8142b779dc22fb3b79 +README.md: 6373f09e44a5bb87679d2afe67321f49c7174fef +README.zh.md: a657e6371677caa97f0884d3f741a95b3cc6c437 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 28f649ef54..6373f09e44 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -76,6 +76,14 @@ The manager derives three internal residency conditions from Agent quiescence an The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input. +### Settlement delivery + +When a resident Activation settles, the manager tells the child's durable direct parent, in the parent's own turn stream, that the child produced everything it is going to. Delivery is unconditional for every child whose id a caller actually received: it does not consider whether the child called `report`, because the endings that most need an account — a token ceiling, a model failure, cancellation, teardown — are exactly the ones where the child never got to choose. A materialization rolled back before its first accepted message stays silent, since that caller was told the child was not established. The message carries the epoch's stop reason, its final assistant content when it produced any, and durable provenance `{ kind: 'subagent-settled', form: 'notice', senderSessionId: }` — a different source kind from a child-authored `subagent-report`, so a transcript never credits the child with words the runtime wrote. + +Two ordering rules make the delivery reliable rather than lucky, and both are why this belongs to the manager instead of an external `subagent/end` listener. First, the send happens **before** the child's ownership release, while the parent still counts the child and is therefore structurally unable to be judged settled. Second, a parent that is itself a resident Activation receives the message through the same waking-admission accounting as a report, so the window between the synchronous send and the microtask that admits it is not mistaken for quiescence — `Agent.status` folds context maintenance into `idle`, and a waking send behind maintenance only arms a deferred wake. Without either rule the parent can be disposed with the notice still in an inbox that `cancel()` clears, which loses it silently. + +An idle parent receives the notice as one ordinary later turn. A busy parent is steered into its nearest step boundary instead, so several children settling together cost one step rather than one turn each; steering rather than injecting also means a driver that retires between the status read and the send still claims the message. A parent whose own lineage is already draining receives the notice by injection, with no wake at all: `Agent.followup()` on a quiescent parent starts a turn and `cancel()` does not arm against a later one, so waking during teardown would spend a model request on an Agent its host is about to dispose — once per tree layer, since each layer's notice then wakes the layer above it. The injected message reaches a parent that is still reading its inbox, and the log records the account either way, but it does not outlive that parent's own disposal: `AgentHandle.dispose()` is a `keepInbox: false` cancel, which durably cancels an unclaimed notice. A resumed parent therefore has no pending notice to read: `list_agents` tells it which children exist and whether each is live or stored, while the outcome itself stays in the child's own Session, which a `send_message` reaches by resuming that child. A parent that has left the registry is not an error: the notice is dropped and the child's own Session remains the durable record. Delivery never blocks or fails teardown — a rejected send is logged, because retaining a child to retry a notice would pin its whole ancestry in `waiting` forever. + A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains child-first. Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement awaits a best-effort `ctx.sessions.flush(child.session)` before handle disposal. A listener rejection is logged without failing the Activation because listener participation does not identify a persistence backend; the persisted state may therefore be missing or stale on resume. ## Lifecycle events @@ -100,11 +108,25 @@ Continuable Activations await a best-effort final session flush without treating ## Model Experience +### Settlement notice + +#### 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 child's final assistant content, or `It left no closing message.` when it produced none. This is the service's only direct parent-side contribution; delegation schemas, parent continuation and discovery, and the child-scoped `report` belong to `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. + +#### Token effect + +One notice per settled Activation in the parent's request, sized by the child's final message. A child that both reports and settles costs the parent both. + +#### KV Cache effect + +Append-only in the parent: the notice follows its reusable request prefix. Reaching an idle parent starts one independent model request; reaching a busy one does not. + ### Child delegation-scope statement #### What the model sees -Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences; parent-side rendering stays with `dsh-tool-subagent` (delegation schemas), `dsh-tool-subagent-control` (continuation and discovery), and `dsh-tool-subagent-report` (the child-scoped `report`). +Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences. ##### The delegation-scope statement diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 595c5e5e7f..a657e63716 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -76,6 +76,14 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。 +### 结算投递 + +当一个驻留 Activation 结算时,管理器会在父级自身的轮次流中告知该子级持久化的直接父级:这个子级已经产出它将产出的全部内容。对每个调用方真正拿到过 id 的子级,这条投递都是无条件的:它不考虑该子级是否调用过 `report`,因为最需要这条投递的结束方式——token 上限、模型失败、取消、拆卸——恰恰是子级根本没有机会选择的那些。在第一条消息被接受之前就回滚的物化保持静默,因为那位调用方已被告知该子级未建立。消息会携带该 epoch 的终止原因、它产出过的最终 assistant 内容,以及持久化来源 `{ kind: 'subagent-settled', form: 'notice', senderSessionId: }`——与子级自撰的 `subagent-report` 是不同的来源 kind,因此 transcript(文本记录)绝不会把运行时写下的话算到子级头上。 + +有两条顺序规则让这条投递可靠而非侥幸,它们也正是这件事属于管理器而非外部 `subagent/end` listener 的原因。第一,发送发生在子级所有权释放**之前**,此时父级仍然计入该子级,因此在结构上不可能被判定为已结算。第二,本身就是驻留 Activation 的父级会通过与 report 相同的唤醒准入记账接收该消息,因此同步发送与承认它的那个 microtask 之间的窗口不会被误判为静止——`Agent.status` 会把上下文维护折叠成 `idle`,而维护期间的唤醒发送只会预置一次延后唤醒。缺少其中任一条规则,父级都可能在通知仍留在 inbox 时被 dispose,而 `cancel()` 会清空该 inbox,于是通知被静默丢失。 + +空闲父级会以一个普通的后续轮次收到该通知。繁忙父级则被 steer 到其最近的 step 边界,因此同时结算的多个子级只消耗一个 step,而不是各自一个轮次;采用 steer 而非 inject 还意味着:即便驱动在状态读取与发送之间退出,该消息仍会被认领。若父级自身所在的谱系已在 draining,则该通知改为 inject 送达,完全不唤醒:对静息父级调用 `Agent.followup()` 会开启一个轮次,而 `cancel()` 不会对之后的轮次设防,因此在拆卸期间唤醒,会在宿主即将 dispose 的 Agent 上白花一次模型请求——而且每层树各一次,因为每层自己的通知又会唤醒它上面那层。被 inject 的消息会送达仍在读取自身 inbox 的父级,而无论如何日志都会记录这份记账;但它不会比该父级自身的 dispose 活得更久:`AgentHandle.dispose()` 是一次 `keepInbox: false` 的 cancel,会持久地取消尚未被认领的通知。因此 resume 后的父级没有待处理通知可读:`list_agents` 只告诉它有哪些子级、各自是在线还是仅存于存储;结局本身留在子级自己的 Session 里,一次 `send_message` 会通过 resume 该子级把它取回。已离开注册表的父级不算错误:通知被丢弃,子级自身的 Session 仍是持久记录。投递绝不会阻塞或使拆卸失败——发送被拒只会记录日志,因为为了重试一条通知而保留子级,会把它的整条祖先链永久钉在 `waiting` 上。 + 受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算会在 dispose handle 前等待 best-effort 的 `ctx.sessions.flush(child.session)`。listener rejection 会被记录,但不会使 Activation 失败,因为 listener 是否参与无法标识持久化后端;因此,恢复时持久化状态可能缺失或陈旧。 ## 生命周期事件 @@ -100,11 +108,25 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 模型体验 +### 结算通知 + +#### 模型看到的内容 + +一条用户角色的父级消息,开头是结果本身——`Background subagent finished and will do no further work unless you send it more.`,或子级被停止、耗尽额度、拒绝任务或失败时的对应句子——随后是 `Its closing message:` 与子级的最终 assistant 内容;若子级没有产出内容,则是 `It left no closing message.`。这是本服务面向父级的唯一直接贡献;委派 schema、父级延续与发现以及子级作用域的 `report` 分别归 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 所有。 + +#### Token 影响 + +父级请求中,每个已结算的 Activation 一条通知,长度取决于子级的最终消息。既上报又结算的子级会让父级同时支付两份。 + +#### KV Cache 影响 + +在父级中仅追加:通知位于其可复用请求前缀之后。到达空闲父级会启动一次独立的模型请求,到达繁忙父级则不会。 + ### 子级委派范围声明 #### 模型看到的内容 -每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后;父级侧的渲染仍归 `dsh-tool-subagent`(委派 schema)、`dsh-tool-subagent-control`(延续与发现)和 `dsh-tool-subagent-report`(子级作用域的 `report`)所有。 +每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后。 ##### 委派范围声明 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 871ec6efdd..073708f4bc 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -1,7 +1,8 @@ /** * Internal continuable-subagent manager: stable child ids, descriptor * persistence, activation admission, the live ownership graph, cold resume, - * and child-first disposal behind `ctx.subagents`. + * child-first disposal, and settlement delivery to the parent, behind + * `ctx.subagents`. * * A continuable child has one durable Session and at most one process-local * {@link Activation} — one residency epoch for a reconstructed child Agent. An @@ -11,6 +12,12 @@ * residency while the Agent loop owns all turn ordering and execution. No * continuable path creates a Task or an intermediate result-bearing wrapper. * + * Because residency is this manager's alone to end, telling the parent that a + * child settled is its job too. An external `subagent/end` listener cannot do + * it correctly: that payload names no parent, the child handle is already + * disposed by then, and the release that wakes the parent's own settlement + * watcher has already run. See {@link SubagentContinuationManager.notifySettlement}. + * * @module @deepseek-ai/dsh-subagent */ @@ -23,7 +30,7 @@ import type { AgentSetupCommit, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' -import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -42,8 +49,8 @@ import { import type { DelegatedPolicyOverrides } from './child-agent.ts' import { assertSubagentMaxDepth } from './depth.ts' import { seedDescriptorTurn } from './descriptor-seed.ts' -import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' -import type { ActivationObserver } from './lifecycle.ts' +import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentResult, SubagentStartRequest } from './types.ts' +import type { ActivationObserver, ActivationTerminal } from './lifecycle.ts' import { SubagentError } from './error.ts' import type SubagentActivationSetupRegistry from './activation-setup-registry.ts' @@ -65,10 +72,28 @@ export interface SubagentReportMessageSource { readonly senderSessionId: SessionId } +/** + * Durable attribution for the runtime's own account of a continuable child + * settling. Deliberately a different kind from + * {@link SubagentReportMessageSource}: a report is content the child 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. + */ +export 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 +} + declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { coordinator: CoordinatorMessageSource 'subagent-report': SubagentReportMessageSource + 'subagent-settled': SubagentSettledMessageSource } } @@ -166,6 +191,13 @@ interface ContinuationHost { interface Activation { /** 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. */ @@ -197,6 +229,12 @@ interface Activation { * microtask that admits it, so settlement must not treat that gap as quiet. */ readonly accepted: Set + /** + * 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-observe quiescence. */ poke: PromiseWithResolvers } @@ -243,6 +281,36 @@ function disposalOf(activation: Activation): Promise | undefined { return activation.disposal } +/** + * One line telling a parent that a background child is finished and why, in + * 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. + * @returns the model-facing opening line of the settlement notice. + */ +function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason']): string { + const subject = `Background subagent ${childId}` + switch (stopReason) { + case 'completed': + return `${subject} finished and will do no further work unless you send it more.` + case 'aborted': + return `${subject} was stopped before it finished.` + case 'max-tokens': + return `${subject} ran out of room before it finished.` + // A pre-step rejection — a hook deny, a policy plugin — discarded input + // the child had claimed, so the parent must not treat the task as done. + case 'refusal': + return `${subject} declined the task.` + case 'error': + return `${subject} failed before it finished.` + /* v8 ignore next 4 -- `SubagentResult['stopReason']` is merge-extensible, so this arm + * needs a backend that adds a variant; an unnameable ending is reported as unfinished + * rather than silently as success. */ + default: + return `${subject} ended abnormally (${String(stopReason)}) before it finished.` + } +} + /** Whether one settlement attempt opened the disposal transaction. */ type SettlementAttempt = | { readonly settling: false } @@ -576,19 +644,36 @@ export class SubagentContinuationManager { senderSessionId: activation.childId, }, }) - const parentActivation = this.activations.get(parent.id) - if (delivery === 'wakeup' - && parentActivation !== undefined - && parentActivation.handle.agent === parent) { - this.admitWaking(parentActivation, message.id, () => { - this.sendReport(parent, message, delivery) - }) + if (delivery === 'wakeup') { + this.sendWaking(parent, message, () => { this.sendReport(parent, message, delivery) }) } else { this.sendReport(parent, message, delivery) } return message.id } + /** + * Perform one waking send to a parent, accounted against that parent's own + * Activation when it has one. Registering the id before the send is what + * keeps a continuation-managed parent from being judged quiescent in the + * window between `followup()` and the microtask that admits it. + * @param parent - the exact live parent receiving the waking message. + * @param message - the message whose id is accounted. + * @param send - the synchronous waking send to perform. + */ + private sendWaking( + parent: Agent, + message: ReturnType, + send: () => void, + ): void { + const parentActivation = this.activations.get(parent.id) + if (parentActivation !== undefined && parentActivation.handle.agent === parent) { + this.admitWaking(parentActivation, message.id, send) + } else { + send() + } + } + /** Send one report while translating only the parent's own rejection. */ private sendReport( parent: Agent, @@ -745,23 +830,32 @@ export class SubagentContinuationManager { return lineage } - /** Reject new admission once the manager or this exact parent tree began draining. */ - private assertAdmitting(agent: Agent): void { - if (this.draining) { - throw new SubagentError( - 'continuable subagents are draining; the operation was not admitted', - 'DRAINING', - ) - } + /** + * The teardown that closed continuable admission for this agent's lineage. + * `'manager'` is the whole manager draining; an Agent is the exact scoped root + * whose forest is closing. + * @param agent - the agent whose lineage is tested. + * @returns the closing teardown, or `undefined` while admission is open. + */ + private closingTeardownFor(agent: Agent): Agent | 'manager' | undefined { + if (this.draining) return 'manager' const lineage = this.liveLineage(agent) for (const [root, members] of this.closingScopes) { - if (members.has(agent) || lineage.includes(root)) { - throw new SubagentError( - `continuable subagents below parent "${root.id}" are draining; the operation was not admitted`, - 'DRAINING', - ) - } + if (members.has(agent) || lineage.includes(root)) return root } + return undefined + } + + /** Reject new admission once the manager or this exact parent tree began draining. */ + private assertAdmitting(agent: Agent): void { + const closing = this.closingTeardownFor(agent) + 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`, + 'DRAINING', + ) } /** @@ -930,6 +1024,10 @@ export class SubagentContinuationManager { const activation: Activation = { childId, + // The durable lineage, not merely the caller: creation stamps this same + // agent into the child's header, and cold resume authorized it against + // the persisted header before materializing. + parentSession: parent.id, provider, handle, ancestry: new WeakSet([handle.agent, ...parentLineage]), @@ -937,6 +1035,7 @@ export class SubagentContinuationManager { observer, disposal: undefined, accepted: new Set(), + announced: false, poke: Promise.withResolvers(), } // After transfer, any failure must dispose the created handle, remove the @@ -1038,9 +1137,13 @@ export class SubagentContinuationManager { // establish it before the message can enter the child's inbox. this.acquireOwnership(parent, activation.childId) const message = createUserMessage({ content, source }) - return this.admitWaking(activation, message.id, () => { + const accepted = this.admitWaking(activation, message.id, () => { activation.handle.agent.followup(message) }) + // Past this point the caller has an id for this child, so its eventual + // settlement is something the parent is owed an account of. + activation.announced = true + return accepted } /** @@ -1261,6 +1364,12 @@ export class SubagentContinuationManager { // makes a racing delivery wait for release rather than cold-resume into the // still-registered agent. this.activations.delete(childId) + // BEFORE releasing ownership, while the parent still counts this child and + // therefore cannot be judged settled. Delivering after the release would + // race a parent watcher that resumes one microtask later, finds itself + // childless and quiet, and disposes an Agent whose `cancel()` clears the + // inbox this notice is sitting in. + this.notifySettlement(activation, activation.observer.terminal(failure)) // Release ownership even on failure: a retained failed child would pin its // ancestors in `waiting` forever. this.releaseOwnership(childId) @@ -1270,6 +1379,75 @@ export class SubagentContinuationManager { if (failure !== undefined) throw failure } + /** + * Tell the durable direct parent that this child produced everything it is + * going to. Unconditional for every child the caller received an id for: it + * does not consider whether the child reported, because the cases that most + * need it — a token ceiling, a model failure, cancellation, teardown — are + * exactly the ones where the child never got to choose. A materialization + * rolled back before its first acceptance stays silent, since the caller was + * told that child was not established. A parent that is no longer live is not + * an error; the child's own Session remains the durable record either way. + * A parent whose own lineage is already closing receives the notice without a + * wake, because teardown is not a reason to start a turn. + * + * Never blocks disposal. A delivery failure is logged and dropped, because + * retaining a child to retry a notice would pin its whole ancestry in + * `waiting` forever. + * @param activation - the settling Activation, still owned by its parent. + * @param terminal - how this epoch ended, as the terminal edge will report it. + */ + private notifySettlement(activation: Activation, terminal: ActivationTerminal): void { + if (!activation.announced) return + try { + const parent = this.ctx.agents.get(activation.parentSession) + if (parent === undefined) return + const summary = settlementSummary(activation.childId, terminal.stopReason) + const message = createUserMessage({ + content: [ + { type: 'text' as const, text: summary }, + ...terminal.output === undefined + ? [{ type: 'text' as const, text: 'It left no closing message.' }] + : [{ type: 'text' as const, text: 'Its closing message:' }, ...terminal.output], + ], + source: { + kind: 'subagent-settled' as const, + form: 'notice' as const, + summary: boundContextSummary(summary), + senderSessionId: activation.childId, + }, + }) + // A parent whose own teardown already began must not be woken. Waking is + // not a queue operation: `followup()` on a quiescent Agent starts a turn, + // and `cancel()` does not arm against a later one, so a notice arriving + // during teardown would spend a model request on an Agent its host is + // about to dispose — once per tree layer, since each layer's own notice + // then wakes the layer above it. Injecting delivers to a parent still + // reading its inbox and records the account in the log either way; it + // does NOT survive that parent's own disposal, whose `keepInbox: false` + // cancel durably clears whatever it never claimed. + if (this.closingTeardownFor(parent) !== undefined) { + parent.inject(message) + return + } + // An idle parent has nothing else to look at, so it gets one ordinary + // turn. A busy parent is steered instead of woken: `Inbox.claim()` takes + // the whole next-step batch at one boundary, so several children settling + // together cost one step rather than one turn each. Steering rather than + // injecting closes the window where a driver retires between this status + // read and the send, which would strand the notice unclaimed. + this.sendWaking(parent, message, () => { + if (parent.status === 'idle') parent.followup(message) + else parent.steer(message) + }) + } catch (error: unknown) { + this.ctx.logger.warn( + `subagent "${activation.childId}" settlement notice was not delivered to its parent: ` + + errorChain(error), + ) + } + } + /** * Request a best-effort final session flush after the child is quiescent. * Listener failure is logged because flush participation cannot identify a diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 36acb8de28..c31d197d25 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -119,6 +119,7 @@ export type { SubagentReportDelivery, SubagentReportMessageSource, SubagentReportOptions, + SubagentSettledMessageSource, } from './continuation.ts' export type { ContinuableSetupContribution } from './activation-setup-registry.ts' export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts' diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 3482a00c0b..2e1d513d5a 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -18,12 +18,23 @@ 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 { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session' +import { foldConsumedWork } from '@deepseek-ai/dsh-agent' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { finalAssistantOutput } from './assistant-output.ts' import { SubagentRunId } from './types.ts' import type { SubagentResult, SubagentRun, 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?: ContentBlock[] +} + /** * Lifecycle observer for one Activation's residency epoch, so continuable * children emit the same start/end pair as one-shot runs. Package-private: the @@ -43,6 +54,15 @@ export interface ActivationObserver { * @param child - the quiescent child agent about to be released. */ capture(child: Agent): 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 @@ -165,9 +185,12 @@ export function createActivationObserver( let boundary = 0 // Assigned by `capture()`, which the disposal path always runs before // `settle()`; a resident epoch therefore always has its facts by then. - let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = { - stopReason: 'completed', - } + let 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 => { boundary = child.session.events.length @@ -181,11 +204,12 @@ export function createActivationObserver( ...output === undefined ? {} : { output }, } }, + terminal, settle: (failure: unknown): void => { - const output = failure === undefined ? captured.output : undefined + const { stopReason, output } = terminal(failure) emit('subagent/end', { ...identity, - stopReason: failure === undefined ? captured.stopReason : 'error', + stopReason, ...output === undefined ? {} : { lastAssistantMessage: output }, }, parent) }, @@ -193,18 +217,24 @@ export function createActivationObserver( } /** - * Why this child's last ordinary turn ended, for the terminal lifecycle edge. - * The child's own `turn/end` is authoritative: teardown succeeding says nothing - * about whether the model errored, hit its token ceiling, or was cancelled, so - * deriving the reason from disposal would report failed work as completed. + * Why this child's epoch ended, for the terminal lifecycle edge and the + * manager's own parent delivery. The child's own log is authoritative: + * teardown succeeding says nothing about whether the model errored, hit its + * token ceiling, or was cancelled, so deriving the reason from disposal would + * report failed work as completed. + * + * {@link foldConsumedWork} supplies both halves the raw turn sequence cannot: + * which turn accounts for the work this epoch consumed, and whether accepted + * work was cancelled after it without any turn opening over it. A recorded + * failure still wins over a cancellation — stopping a child that had already + * failed does not turn its failure into a cancellation. * @param events - this epoch's own event suffix. - * @returns its terminal stop reason; `completed` when no ordinary turn closed. + * @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'] { - const reason = findLastMessageTurnEnd(events)?.data.reason - // No ordinary turn closed, so nothing failed either. - if (reason === undefined) return 'completed' - switch (reason.kind) { + const { end, droppedUnrun } = foldConsumedWork(events) + switch (end?.data.reason.kind) { case 'max-tokens': return 'max-tokens' case 'aborted': @@ -212,8 +242,15 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR return 'aborted' case 'error': return 'error' + // A pre-step rejection — a hook deny, a policy plugin — discarded input + // this epoch had claimed: the work was declined, not done. + case 'blocked': + return 'refusal' + // A clean ending and no accounting turn at all share one rule: the epoch + // finished what it was given unless a cancelled queue says otherwise. + case undefined: case 'completed': - return 'completed' + return droppedUnrun ? 'aborted' : 'completed' /* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a * backend that adds a variant; treating an unnameable reason as success would * report failed work as completed. */ diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 50bd71b9cc..828717c1aa 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -15,7 +15,7 @@ import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-l import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import InvariantService from '@deepseek-ai/dsh-invariants' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { SubagentError, SUBAGENT_DESCRIPTOR_VERSION, @@ -142,6 +142,18 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise }, { timeout: 5_000 }) } +/** + * Keep the top-level test parent out of a scripted model corpus. Every child + * settlement wakes its parent, so a suite that scripts only child responses + * would otherwise spend them on the parent's own turns. + */ +function parkParent(ctx: Context, parent: Agent): void { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { + if (subject !== parent) return next() + return { kind: 'reject' as const } + }) +} + /** Observe calls at the Agent cancellation boundary without a production event. */ function observeCancel(agent: Agent, callback: () => void): void { const cancel = agent.cancel.bind(agent) @@ -531,7 +543,10 @@ describe('SubagentService.followup residency routing', () => { await waitNoActivation(ctx, grandchild.childId) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting']) + // This child is itself a parent, so its grandchild's settlement notice is + // an ordinary later user message in its log. + expect(userTexts(loaded.events).slice(0, 2)).toEqual(['child task', 'while waiting']) + expect(userTexts(loaded.events).slice(2).join('\n')).toContain('finished and will do no further work') }) it('rejects a parent that is not the durable direct parent', async () => { @@ -1182,6 +1197,7 @@ describe('continuable review regressions', () => { it('reports this epoch\'s own output, captured while the child was still live', async () => { const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) + parkParent(ctx, parent) const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) @@ -1254,9 +1270,10 @@ describe('continuable review regressions', () => { await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) - // Reading the whole session would resurrect 'first answer' here. + // Reading the whole session would resurrect 'first answer' here. The + // rejection discarded the claimed follow-up, so the epoch reads as refused. expect(ends[0]!.lastAssistantMessage).toBeUndefined() - expect(ends[0]!.stopReason).toBe('completed') + expect(ends[0]!.stopReason).toBe('refusal') }) it('reports handle-disposal failure on the terminal edge', async () => { @@ -1439,11 +1456,13 @@ describe('continuable review regressions', () => { await waitNoActivation(ctx, started.childId) }) - it('reports completed when no ordinary turn closed', async () => { + it('reports a prompt a pre-step rejection discarded as refusal', async () => { const { ctx, parent } = await setup([]) + parkParent(ctx, parent) const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) - // Block admission so the child's only turn never opens. + // A UserPromptSubmit deny or a policy plugin: the child claims its prompt, + // the rejection discards it, and no step ever runs. ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } @@ -1452,8 +1471,10 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) + // The parent would otherwise believe a vetoed delivery was done and never + // resend it — the one failure the settlement promise says cannot happen. await vi.waitFor(() => { expect(ends).toHaveLength(1) }) - expect(ends[0]!.stopReason).toBe('completed') + expect(ends[0]!.stopReason).toBe('refusal') }) it('retains the Activation while an accepted message is still in the inbox', async () => { @@ -1482,15 +1503,538 @@ describe('continuable review regressions', () => { expect(ctx.agents.get(started.childId)).toBe(child) releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) - expect(adapter.requests).toHaveLength(2) + // Two child turns; the third request is the parent's own turn on the + // settlement notice. + expect(adapter.requests.filter(request => request.sessionId === started.childId)).toHaveLength(2) const loaded = await ctx.sessionPersistence.load(started.childId) expect(hasUserText(loaded.events, 'queued')).toBe(true) }) }) +/** Every settlement notice this agent received, in order, as flat text. */ +function settlementNotices(agent: Agent): { sender: string; text: string; summary: string }[] { + const logged = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + return [...logged, ...agent.inbox.nextStep, ...agent.inbox.nextTurn].flatMap((message) => { + if (message.source.kind !== 'subagent-settled') return [] + return [{ + sender: message.source.senderSessionId, + summary: message.source.summary, + text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'), + }] + }) +} + +describe('continuable settlement delivery', () => { + it('tells the parent what the child finished with, without being asked', async () => { + const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) + const started = await ctx.subagents.startContinuable(startSpec(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', + ) + // The collapsed row states the outcome without the child's content. + expect(notice.summary).toBe( + `Background subagent ${started.childId} finished and will do no further work unless you send it more.`, + ) + }) + + it('delivers even when the child already reported for itself', async () => { + const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const live = ctx.agents.get(started.childId) + expect(live).toBeDefined() + return live! + }) + await ctx.subagents.reportFrom(child, message('an explicit report'), { + delivery: 'quiet', + signal: testSignal, + }) + await waitNoActivation(ctx, started.childId) + + // The contract is unconditional precisely so the parent-side tool + // description can promise it; bookkeeping "did it report?" would make the + // promise conditional on a channel this manager does not own. + await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) + }) + + 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)) + 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', + ) + }) + + it('tells the parent a policy-rejected delivery was declined, not finished', async () => { + const { ctx, parent } = await setup([textResponse('parent ack')]) + // A pre-step rejection on the child — a UserPromptSubmit deny, a policy + // plugin — discards the claimed prompt without running it. + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { + if (subject === parent) return next() + return { kind: 'reject' } + }) + + const started = await ctx.subagents.startContinuable(startSpec(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.', + ) + }) + + it('reports a turn that failed before reaching its first step', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('the answer'), gate: releaseFirst.promise }, + { chunks: textResponse('parent ack') }, + ]) + const { ctx, parent } = await setupWith(adapter) + // The shipped durability checkpoint (`dsh-session-checkpoint-policy`) is + // fail-closed at the step boundary, so a rejected write ends the turn after + // it claimed its messages and before it entered a step. + ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => { + if (subject.session.header.parentSession === undefined || turn < 2) return next() + throw new Error('ENOSPC: no space left on device') + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await followup(ctx, parent, started.childId, message('second task')) + releaseFirst.resolve(undefined) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) + // The parent must not be told the child finished: the delivery it is still + // waiting on was claimed out of the inbox and then swallowed by the failure. + const child = await ctx.sessionPersistence.load(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', + ) + }) + + it('reports accepted work cut short before its first step as stopped', async () => { + const releaseFirst = Promise.withResolvers() + const releaseCheckpoint = Promise.withResolvers() + const atCheckpoint = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('the answer'), gate: releaseFirst.promise }]) + const { ctx, parent } = await setupWith(adapter) + // A step-boundary participant — the shipped durability checkpoint, a hook, + // prompt assembly — holding the child's second turn open before its first + // step, which is where teardown cancellation then catches it. + ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => { + if (subject.session.header.parentSession === undefined || turn < 2) return next() + atCheckpoint.resolve(undefined) + await releaseCheckpoint.promise + return next() + }) + + const started = await ctx.subagents.startContinuable(startSpec(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 followup(ctx, parent, started.childId, message('second task')) + releaseFirst.resolve(undefined) + await atCheckpoint.promise + const drained = drainManager(ctx) + releaseCheckpoint.resolve(undefined) + await drained + + // Turn 2 leaves a balanced no-step `aborted` end, so the log alone would + // answer with turn 1's clean completion and tell the parent its still-unrun + // 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', + ) + }) + + it('reports a child stopped before it ever reached the model as stopped', async () => { + const releaseCheckpoint = Promise.withResolvers() + const atCheckpoint = Promise.withResolvers() + const { ctx, parent } = await setupWith(new GatedAdapter([])) + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { + if (subject.session.header.parentSession === undefined) return next() + atCheckpoint.resolve(undefined) + await releaseCheckpoint.promise + return next() + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await atCheckpoint.promise + const drained = drainManager(ctx) + releaseCheckpoint.resolve(undefined) + await drained + + // This epoch closed no stepped turn at all, which on its own reads as "had + // nothing to report"; only the interruption distinguishes it from a child + // 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.', + ) + }) + + it('reports a child an ancestor interrupted before its first step as stopped', async () => { + const atCheckpoint = Promise.withResolvers() + const releaseCheckpoint = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('parent ack') }]) + const { ctx, parent } = await setupWith(adapter) + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { + if (subject.session.header.parentSession === undefined) return next() + atCheckpoint.resolve(undefined) + await releaseCheckpoint.promise + return next() + }) + + const started = await ctx.subagents.startContinuable(startSpec(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. + ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent }) + releaseCheckpoint.resolve(undefined) + await waitNoActivation(ctx, started.childId) + + 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.', + ) + }) + + it('reports accepted work cancelled before any turn could open as stopped', async () => { + const releaseChild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const releaseMaintenance = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('the answer'), gate: releaseChild.promise }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const live = ctx.agents.get(started.childId) + expect(live).toBeDefined() + return live! + }) + // 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)) + await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) + releaseChild.resolve(undefined) + await vi.waitFor(() => { expect(child.status).toBe('idle') }) + + // Context maintenance folds into `idle` and defers waking work, so this + // delivery is accepted with no turn to claim it. + const maintaining = child.runMaintenance(async () => { await releaseMaintenance.promise }) + await followup(ctx, parent, started.childId, message('never runs')) + const drained = drainManager(ctx) + releaseMaintenance.resolve(undefined) + releaseGrandchild.resolve(undefined) + await maintaining + await drained + + // Turn 1 closed cleanly and no later turn opened, so the cancelled queue is + // the only record that this epoch was cut short. + expect(hasUserText(child.session.events, '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', + ) + }) + + 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 manager = (ctx.subagents as unknown as { + continuations: { activations: Map } }> } + }).continuations + const activation = await vi.waitFor(() => { + const live = manager.activations.get(started.childId) + expect(live).toBeDefined() + return live! + }) + const dispose = activation.handle.dispose.bind(activation.handle) + activation.handle.dispose = async () => { + await dispose() + throw new Error('scope unwind failed') + } + + 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.`, + ) + }) + + it('gives an idle parent one ordinary turn on the notice', async () => { + const { ctx, parent, adapter } = await setup([textResponse('the answer'), textResponse('parent ack')]) + const turnStarts: number[] = [] + ctx.on('session/event', (session, event) => { + if (session.id === parent.id && event.type === 'turn/start') turnStarts.push(event.data.turn) + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { + expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) + }) + expect(turnStarts).toEqual([1]) + }) + + it('batches simultaneous notices into one step of a busy parent', async () => { + const releaseChildren = Promise.withResolvers() + const releaseParent = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('parent works'), gate: releaseParent.promise }, + { chunks: textResponse('first child'), gate: releaseChildren.promise }, + { chunks: textResponse('second child'), gate: releaseChildren.promise }, + { chunks: textResponse('parent reacts') }, + ]) + const { ctx, parent } = await setupWith(adapter) + // Open a parent turn first, so both notices arrive while it is running. + 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)) + releaseChildren.resolve(undefined) + await waitNoActivation(ctx, first.childId) + await waitNoActivation(ctx, second.childId) + + // Both notices are waiting for the same step boundary, not two turns. + expect(parent.inbox.nextStep).toHaveLength(2) + expect(parent.inbox.nextTurn).toHaveLength(0) + const turnStarts: number[] = [] + ctx.on('session/event', (session, event) => { + if (session.id === parent.id && event.type === 'turn/start') turnStarts.push(event.data.turn) + }) + releaseParent.resolve(undefined) + await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(2) }) + expect(turnStarts).toEqual([]) + // Both children released together, so which settles first is not ordered. + expect(new Set(settlementNotices(parent).map(entry => entry.sender))) + .toEqual(new Set([first.childId, second.childId])) + }) + + it('holds a maintaining parent live until it can read the notice', async () => { + const releaseFirst = Promise.withResolvers() + const releaseSecond = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('outer') }, + { chunks: textResponse('first inner'), gate: releaseFirst.promise }, + { chunks: textResponse('second inner'), gate: releaseSecond.promise }, + { chunks: textResponse('outer reacts') }, + { chunks: textResponse('root reacts') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const outer = await ctx.subagents.startContinuable(startSpec(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)) + await vi.waitFor(() => { expect(middle.status).toBe('idle') }) + + // `Agent.status` folds maintenance into `idle`, and a waking send behind it + // only arms a deferred wake. The first child's release moves the middle + // Activation's settlement watcher onto its quiescence race; the second one + // then arrives at exactly the point where an unaccounted delivery would be + // judged quiet, settled, and cancelled — clearing the inbox it sits in. + const maintaining = Promise.withResolvers() + const maintenance = middle.runMaintenance(async () => { await maintaining.promise }) + releaseFirst.resolve(undefined) + await waitNoActivation(ctx, first.childId) + releaseSecond.resolve(undefined) + await waitNoActivation(ctx, second.childId) + expect(ctx.agents.get(outer.childId)).toBe(middle) + + maintaining.resolve(undefined) + await maintenance + await vi.waitFor(() => { expect(settlementNotices(middle)).toHaveLength(2) }) + expect(settlementNotices(middle).map(entry => entry.sender)) + .toEqual([first.childId, second.childId]) + await waitNoActivation(ctx, outer.childId) + }) + + it('delivers before releasing the ownership that lets the parent settle', async () => { + const releaseChild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('outer') }, + { chunks: textResponse('inner'), gate: releaseChild.promise }, + { chunks: textResponse('outer reacts') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const outer = await ctx.subagents.startContinuable(startSpec(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)) + await vi.waitFor(() => { expect(middle.status).toBe('idle') }) + + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map }> } + }).continuations + let ownedAtDelivery: SessionId[] | undefined + ctx.on('agent/inbox/inserted', ({ agent, message }) => { + if (agent !== middle || message.source.kind !== 'subagent-settled') return + ownedAtDelivery = [...manager.activations.get(middle.id)!.ownedChildren] + }) + + releaseChild.resolve(undefined) + await waitNoActivation(ctx, inner.childId) + // Still owned at delivery: the parent is structurally unable to settle in + // the window the notice crosses, rather than winning a race against it. + expect(ownedAtDelivery).toEqual([inner.childId]) + await waitNoActivation(ctx, outer.childId) + }) + + it('does not wake a parent whose own teardown already began', async () => { + 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)) + await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) + + const drained = drainManager(ctx) + hold.resolve(undefined) + await drained + + // Delivered and durably logged, but no turn: waking a parent the host is + // about to dispose spends a model request nothing reads. What happens to the + // 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.', + ) + expect(parent.session.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true) + expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(parent.status).toBe('idle') + }) + + it('does not wake a parent below a scoped teardown root', async () => { + 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)) + await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) + + const drained = ctx.subagents.drainContinuableDescendants([parent]) + hold.resolve(undefined) + await drained + + expect(settlementNotices(parent)).toHaveLength(1) + expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false) + }) + + it('records but cannot deliver a teardown notice once the parent is disposed too', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }]) + const { ctx } = await setupWith(adapter) + const parentId = SessionId('closing-parent') + const host = await ctx.agents.create({ + sessionId: parentId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const started = await ctx.subagents.startContinuable(startSpec(host.agent)) + await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) + + const drained = ctx.subagents.drainContinuableDescendants([host.agent]) + hold.resolve(undefined) + await drained + expect(settlementNotices(host.agent)).toHaveLength(1) + + // Disposal is a `keepInbox: false` cancel, so it durably cancels the notice + // it never claimed. Teardown delivery therefore reaches a parent that is + // still resident — a resumed one reads the log, not a pending message — and + // no wording anywhere may promise otherwise. + await host.dispose() + const resumed = await ctx.agents.resume({ + resumeSessionId: parentId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(settlementNotices(resumed.agent)).toEqual([]) + await resumed.dispose() + // The account is still in the durable log: delivered, then cancelled unread. + const persisted = await ctx.sessionPersistence.load(parentId) + expect(persisted.events.flatMap(event => event.type === 'agent/inbox/spliced' + ? [{ inserted: event.data.inserted.length, removed: event.data.removedCount ?? 0 }] + : [])).toEqual([{ inserted: 1, removed: 0 }, { inserted: 0, removed: 1 }]) + }) + + it('drops the notice without disturbing teardown when the parent is gone', async () => { + const releaseChild = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: releaseChild.promise }]) + const { ctx } = await setupWith(adapter) + const warnings: string[] = [] + ctx.logger.warn = (text: string) => { warnings.push(text) } + const host = await ctx.agents.create({ + sessionId: SessionId('disposable-parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const started = await ctx.subagents.startContinuable(startSpec(host.agent)) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + releaseChild.resolve(undefined) + await host.dispose() + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(warnings).toEqual([]) + }) + + it('logs a rejected notice instead of failing the child\'s teardown', async () => { + const { ctx, parent } = await setup([textResponse('the answer')]) + const warnings: string[] = [] + ctx.logger.warn = (text: string) => { warnings.push(text) } + vi.spyOn(parent, 'followup').mockImplementation(() => { + throw new Error('parent closed during delivery') + }) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('completed') + expect(warnings.some(warning => warning.includes('settlement notice was not delivered'))).toBe(true) + }) + + it('stays silent about a child the caller was told does not exist', async () => { + const { ctx, parent } = await setup([]) + const drains: Promise[] = [] + ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) + + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await Promise.all(drains) + expect(settlementNotices(parent)).toEqual([]) + }) +}) + describe('continuable lifecycle observation', () => { it('emits one paired start/end per residency epoch', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + parkParent(ctx, parent) const starts: SubagentRunInfo[] = [] const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/start', (info) => { starts.push(info) }) @@ -1510,6 +2054,8 @@ describe('continuable lifecycle observation', () => { expect(starts.map(info => info.provider)).toEqual(['spawn', 'spawn']) // Each end pairs its own start's runId. expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId)) + // Both epochs ran their own scripted response; neither exhausted the corpus. + expect(ends.map(info => info.stopReason)).toEqual(['completed', 'completed']) }) }) 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 1c12602dd2..21cb242844 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -16,6 +16,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as tool from '../src/list-agents.ts' +import { parkParent } from './park-parent.ts' /** One scripted response that may wait on a caller-released gate before streaming. */ interface GatedEntry { @@ -63,6 +64,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) { await ctx.plugin(tool) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + parkParent(ctx, parent) return { ctx, parent, adapter } } diff --git a/packages/subagent/tool-subagent-control/tests/park-parent.ts b/packages/subagent/tool-subagent-control/tests/park-parent.ts new file mode 100644 index 0000000000..7f61f55d51 --- /dev/null +++ b/packages/subagent/tool-subagent-control/tests/park-parent.ts @@ -0,0 +1,22 @@ +/** + * Shared suite helper: keep this package's stand-in parent out of a scripted + * model corpus. + * @module park-parent + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** + * Reject every step of the stand-in parent. Each child settlement wakes its + * parent, and these suites size their scripts for child turns only; the tests + * assert on delivery rather than on the parent's own turn. + * @param ctx - the booted test context. + * @param parent - the stand-in parent whose turns must not reach the model. + */ +export function parkParent(ctx: Context, parent: { id: SessionId }): void { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { + if (subject.id !== parent.id) return next() + return { kind: 'reject' as const } + }) +} diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 4c378fcbc6..4960992068 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -15,6 +15,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as tool from '../src/index.ts' +import { parkParent } from './park-parent.ts' /** One scripted response that may wait on a caller-released gate before streaming. */ interface GatedEntry { @@ -62,6 +63,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) { await ctx.plugin(tool) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + parkParent(ctx, parent) return { ctx, parent, adapter } } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 53a84294cf..ac106a5680 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -549,6 +549,9 @@ describe('dsh-tool-subagent-report result independence', () => { expect(ctx.agents.get(started.childId) === undefined).toBe(true) }, { timeout: 5_000 }) + // The parent does learn the child settled — that account is the + // continuation service's, carried under its own `subagent-settled` source. + // Nothing turns the child's final answer into a report it did not send. expect(reports(parent)).toEqual([]) expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task']) expect(ctx.get('tasks')).toBeUndefined() diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 8c41c6413a..0292c0a51c 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: ac3ec0563cce9128608ca31860b034a103dc1a3a -README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0 +README.md: a46300cc5dffcbc420ed9525d8f081c670771294 +README.zh.md: 985d33beb2ac7de60527b457c4786a0257232611 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index ac3ec0563c..a46300cc5d 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics. -With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The parent is not left guessing when to look, though: the continuation service delivers one settlement notice to it whenever a continuable child's Activation ends, which is why the schema tells the model it will be told and must not poll. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -37,7 +37,7 @@ Foreground and background calls are exclusive. Children may share the parent's w #### What the model sees -The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation and returns its subagent id, while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation, returns its subagent id, and reports its own completion — so the model is told never to poll or wait on it — while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`. #### Token effect @@ -65,11 +65,11 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent task ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode the child does not report back; an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its output. +Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent task ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output. #### Token effect -The acknowledgement is retained; a one-shot final output enters parent history only when collected or injected, while a continuable child's output never returns through this tool. +The acknowledgement is retained; a one-shot final output enters parent history only when collected or injected, while a continuable child's output never returns through this tool — its settlement notice arrives independently of any tool result. #### KV Cache effect @@ -77,6 +77,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. +- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries its closing message, but it is not this call's return value and cannot be awaited here. - **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names. - **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index d64831d7cf..985d33beb2 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -10,7 +10,7 @@ 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 -设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript(文本记录)即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。不过父级无需猜测何时查看:每当可继续子 agent 的 Activation 结束,继续执行服务都会向父级投递一条结算通知——正因如此,schema 才告诉模型它会被通知,且不得轮询。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -37,7 +37,7 @@ #### 模型看到的内容 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话并返回子 agent id 的后台子 agent,而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话、返回子 agent id 并会自行报告完成的后台子 agent——因此模型被告知绝不要轮询或等待它——而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。 #### Token 影响 @@ -65,11 +65,11 @@ #### 模型看到的内容 -在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent task `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,子 agent 不会回报;独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其输出来源。 +在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent task `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 #### Token 影响 -确认消息会被保留;一次性最终输出只在收集或注入时进入父级历史,而可继续子 agent 的输出绝不会通过本工具返回。 +确认消息会被保留;一次性最终输出只在收集或注入时进入父级历史,而可继续子 agent 的输出绝不会通过本工具返回——其结算通知独立于任何工具结果到达。 #### KV Cache 影响 @@ -77,6 +77,6 @@ ## 已知限制与暂缓事项 -- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。 +- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束并携带其收尾消息,但它不是本次调用的返回值,也无法在此等待。 - **等待中实例的重复名称发现较晚**(`TODO(subagent-dup-toolname)`):若要阻止提供方注册回滚,需要一份预期名称注册表。 - **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 13781bde91..bda7f8ae89 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -262,12 +262,13 @@ export function apply(ctx: Context, config: Config): void { disposeTool = ctx.tools.register(defineTool({ name: config.toolName ?? 'subagent', description: wording.description + (backgroundEnabled - // The return channel is a separately installed capability this package - // cannot observe, so this describes only this call's result. + // The completion notice is the continuation service's own behavior, not + // a separately installed capability, so this promise holds whenever the + // continuable background path is reachable at all. ? continuable ? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:' - + ' you receive only its subagent id, never its result, and it works on its own. Use this for' - + ' work whose result you do not need returned by this call; `send_message` sends it more work.' + + ' this call returns only its subagent id, and the subagent works on its own from there. You' + + ' are told when it finishes, so never poll or wait on it; `send_message` sends it more work.' : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { @@ -286,7 +287,7 @@ export function apply(ctx: Context, config: Config): void { type: 'boolean' as const, description: continuable ? 'Run as a background subagent that keeps its conversation and return only its subagent id. ' - + 'This call never returns its result; send it more work with send_message.' + + 'This call does not wait for it; you are told when it finishes. Send it more work with send_message.' : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c1b1b2753a..9bf4d8ceb0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1165,6 +1165,11 @@ "symbol": "SubagentReportDelivery", "source": "packages/subagent/subagent/src/continuation.ts" }, + { + "doc": "docs/subsystems/subagent.md", + "symbol": "SubagentSettledMessageSource", + "source": "packages/subagent/subagent/src/continuation.ts" + }, { "doc": "docs/subsystems/subagent.md", "symbol": "SubagentReportOptions", From 567fcadba011088cadec538873ae837c65496fb0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 11 Aug 2026 12:38:57 +0800 Subject: [PATCH 49/67] fix(subagent): report resumable children as ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_agents called a storage-only continuable child 'complete', inviting parents to poll it for a result that will never appear there. Rename the status to 'ready' — resumable, not terminal — and rewrite the tool description to say a parent is told when a child finishes, so listing is for recall, not polling. --- ...list-agents-residency-vocabulary.i18n.yaml | 6 +++ ...-08-06-list-agents-residency-vocabulary.md | 40 +++++++++++++++++++ ...-06-list-agents-residency-vocabulary.zh.md | 40 +++++++++++++++++++ ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 6 +-- ...ble-subagent-catalog-and-list-agents.zh.md | 6 +-- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 2 +- docs/subsystems/subagent.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 2 +- .../both-mode-turn/tool-schemas.expected.json | 2 +- .../code-mode-turn/system-prompt.expected.md | 4 +- .../lsp-definition/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../pty-tools/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.1.expected.json | 2 +- .../tool-schemas.1.expected.json | 2 +- .../session.1.jsonl | 2 +- .../session.2.jsonl | 2 +- .../subagent-list-agents/session.jsonl | 2 +- .../tool-schemas.1.expected.json | 2 +- .../snapshots/subagent-mixed/session.1.jsonl | 2 +- .../snapshots/subagent-mixed/session.2.jsonl | 2 +- .../snapshots/subagent-multi/session.1.jsonl | 2 +- .../snapshots/subagent-multi/session.2.jsonl | 2 +- .../tool-schemas.1.expected.json | 2 +- .../text-turn/tool-schemas.expected.json | 2 +- .../web-fetch/tool-schemas.expected.json | 2 +- .../advanced-toolchain/session.1.jsonl | 8 ++-- .../advanced-toolchain/session.2.jsonl | 8 ++-- .../advanced-toolchain/session.jsonl | 26 ++++++------ .../compaction-recovery/session.jsonl | 12 +++--- .../tests/snapshots/pty-tools/session.jsonl | 32 +++++++-------- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 6 +-- .../tool-subagent-control/README.zh.md | 6 +-- .../tool-subagent-control/src/list-agents.ts | 20 ++++++---- .../tests/list-agents.spec.ts | 22 ++++++++-- 50 files changed, 215 insertions(+), 109 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.i18n.yaml new file mode 100644 index 0000000000..5f01146dbe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.md +2026-08-06-list-agents-residency-vocabulary.md: 01fff958921465909f5cd300dc355fdac6ec0772 +2026-08-06-list-agents-residency-vocabulary.zh.md: d68b9857de23ccb0b68f0bfafba153fde63303a7 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.md b/.agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.md new file mode 100644 index 0000000000..01fff95892 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-list-agents-residency-vocabulary.md @@ -0,0 +1,40 @@ +# Agent Note: `list_agents` uses `ready` for resumable children + +Status: implemented + +English | [中文](2026-08-06-list-agents-residency-vocabulary.zh.md) + +## Problem + +`list_agents` projected a continuable child's process residency as `running | idle | complete`. `complete` reads as a terminal unit of work with a result somewhere, but the underlying fact says only that no Activation is resident: the conversation is intact, `send_message` can continue it, and nothing about the child's outcome is being claimed. A model that reads `complete` reasonably looks for a result to collect or sends replacement work to a conversation it believes has ended. + +The word is especially misleading alongside [manager-owned settlement delivery](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.md). Completion reaches the parent as a notice; listing exists to recall durable conversations, not to poll for that notice. + +## Decision + +The model-facing projection reports `running | idle | ready`: + +- **`running`** means the resident Agent has an active driver. +- **`idle`** means the Agent is resident between turns and may be waiting on agents it started. +- **`ready`** means only the durable conversation remains. `send_message` starts the next turn on the same conversation; the status is resumable rather than terminal and does not mean a result is waiting to be collected. + +The tool description states those distinctions and directs the model away from polling: it says the parent is told when a child finishes and that listing is for recalling which children it started. `send_message` remains the authoritative delivery check because either snapshot may race another process or a later message. + +The service layer is unchanged. `SubagentListEntry.activity` retains `'running' | 'inactive'`, which accurately describes corpus residency for consumers such as a UI. The model-facing adapter maps `inactive` to `ready` because that word communicates the action available to the model without inventing an outcome. + +## Alternatives considered + +**Keep `complete` and qualify it in the description.** A description saying that `complete` does not mean complete fights the rendered status on every read. The line the model scans must carry the correct distinction itself. + +**Use `active | dormant`.** This removes the useful distinction between a resident Agent that is between turns and a storage-only conversation, and makes the storage-only state sound unavailable. `ready` states the useful fact: the same conversation accepts another turn. + +**Drop the status entirely.** Residency remains useful when a parent decides whether to send more work. Removing it trades one misleading status for no signal. + +**Rename the service activity values.** `running | inactive` is correct at the service layer and has non-model consumers. Renaming it would churn a general contract to fix one adapter's presentation; the [durable catalog note](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) continues to own that service vocabulary. + +## Consequences + +- The rendered line uses ` [running] —