From 9820b6a1e9c3604555b3451d650cd58b64e9f3c6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 22:57:58 +0800 Subject: [PATCH 001/138] fix(cli): derive the shipped agent-preset root per composition The boot-time agent-presets overlay replaced the composed roots with the shipped root alone, so roots configured in a profile's cordis.patch.yml vanished from the roster (externally reported in deepseek-ai/deepseek-harness#3636). The overlay also froze the row's boot-time config above every live reload and never reached the config dump, which therefore showed roots the boot dropped. Derive the roster patch from the current layers instead: prepend the shipped root (system trust, wins duplicate ids) to configured roots, share one builder across boot, live user-layer reloads, and --dump-config, and fail loud on a roots value the launcher cannot statically rewrite. Fixes #2863. --- ...pped-preset-root-per-composition.i18n.yaml | 6 + ...ive-shipped-preset-root-per-composition.md | 31 +++++ ...-shipped-preset-root-per-composition.zh.md | 31 +++++ apps/cli/src/dump-config.ts | 14 ++- apps/cli/src/profile-boot.ts | 111 +++++++++++++----- apps/cli/tests/built-bin.e2e.ts | 13 ++ apps/cli/tests/shipped-preset-root.spec.ts | 89 ++++++++++++++ apps/cli/tests/web-agent-presets.e2e.ts | 107 ++++++++++++----- packages/bundle/web-app/cordis.patch.yml | 8 +- 9 files changed, 346 insertions(+), 64 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md create mode 100644 apps/cli/tests/shipped-preset-root.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml new file mode 100644 index 0000000000..f5c3964d7a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.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-20-derive-shipped-preset-root-per-composition.md +2026-08-20-derive-shipped-preset-root-per-composition.md: b303f6a5d08ac2c2ca755d5dbf46eb9a74f5c4ee +2026-08-20-derive-shipped-preset-root-per-composition.zh.md: cc28789898a74df285a96b7e17c35e3b4d12c452 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md new file mode 100644 index 0000000000..b303f6a5d0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md @@ -0,0 +1,31 @@ +# Agent Note: Derive the shipped preset root per composition + +Status: implemented + +English | [中文](2026-08-20-derive-shipped-preset-root-per-composition.zh.md) + +## Problem + +`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a shared preset directory booted with only the shipped root plus the roster's own writable home root, and every custom preset vanished from the Web picker. `dsh --dump-config` composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them — the include's own contract that a dump can never drift from what boots was broken by a patch the dump never saw. Externally reported with an accurate root cause in discussion #3636. + +The overlay also sat in `ComposedProfile.overlays`, the fixed top layers a live reload replays above fresh user layers. Overlays exist so a user edit cannot displace launcher facts, which is right for `--patch` files and the telemetry switch — but the roster patch had captured the whole boot-time `config`, so after boot no `cordis.patch.yml` edit to the row (`default`, `includeUserRoot`, `roots`) could take effect until restart. + +## Decision + +The shipped root is a derivation, not an overlay. `resolveShippedPresetPatch(rows)` builds the roster patch from one composed row set: it keeps every configured key and prepends the shipped root (`system` trust) to the composition's `roots`, so the shipped presets always mount and win a duplicate id while configured roots stay live. `composeProfilePatches(layers)` appends that patch to the flattened stack and is the one builder boot, the live user-layer reloads, and the config dump all go through — a reload derives from the current user layers instead of replaying a boot snapshot, and the dump now renders the derived layer (labeled `dsh launcher (shipped agent-preset root)`) so it composes the roster row exactly as it boots. The telemetry switch stays a boot-only overlay: it is an environment fact of the booting process, carries no config snapshot, and outranking user edits is its purpose. + +A `roots` value the launcher cannot statically rewrite — a `!!js` expression or any non-array — now fails loud with a `TypeError` naming the constraint, instead of being silently replaced. The plugin's own contract is untouched: `config.roots` scanned in order, the writable home root appended by `dsh-agent-presets` itself. + +## Testing + +`shipped-preset-root.spec.ts` covers the derivation directly: prepend order, key preservation, absence without a roster row, per-call derivation, the fail-loud rejections, and the squash regression through a full `composeEntries` application. The Web composition e2e now obtains the shipped root through the real `composeProfilePatches` instead of hand-writing the launcher's patch (three boots had replicated it literally, one admitting "exactly what `composeProfile` supplies"), and adds a configured-roots boot: a shared root's preset lists beside the shipped four, a directory claiming a shipped id is shadowed by it, and a configured-root preset composes an agent. The built-bin dump acceptance asserts the derived layer's label and the shipped-before-configured root order. No keyless snapshot changes: default compositions produce byte-identical stacks, and the snapshot harness has no custom-profile lane — the real-composition e2e is the assembled-application evidence here. + +## Alternatives considered + +**The reporter's fix: prepend inside the boot-time overlay.** Correct on the squash and the priority order, and kept as the shape of the derived patch. Rejected as-is because the overlay would still freeze the whole boot-time `config` above every later reload, leaving the row's live edits dead until restart. + +**Provide the shipped root out of band (a launcher-provided context value the plugin prepends).** Cleanest hot-reload story — no config rewriting at all — but it moves an assembly fact into the plugin's service contract, adds a launcher-coupled provide key to a package that otherwise only reads config, and makes the effective roots invisible to the config dump. The derived patch keeps the roster's inputs entirely in the composition. + +## Consequences + +Configured preset roots survive boot, live edits to the roster row take effect without restart, and the dump, the live tree, and the boot compose the row identically. The launcher constrains the roster row's `config`/`roots` to literal values; a composition that generated them with `!!js` would previously have had the expression silently discarded and now must materialize the array in a patch layer instead. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md new file mode 100644 index 0000000000..cc28789898 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Derive the shipped preset root per composition + +Status: implemented + +[English](2026-08-20-derive-shipped-preset-root-per-composition.md) | 中文 + +## 问题 + +`composeProfile` 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 `config` 展开已组合的 roster 行后,把 `roots` 硬设为仅含内置根。由于 id 定向补丁会整体替换 `config` 值,这个 overlay 压掉了 profile 的 `cordis.patch.yml`(以及 home 层、`--patch` overlay)配置的全部根目录:把 `agent-presets` 指向共享 preset 目录的部署,启动后只剩内置根加 roster 自己的可写 home 根,所有自定义 preset 从 Web 选择器中消失。`dsh --dump-config` 只组合文件承载的层,所以 dump 显示配置的根目录完好而启动却丢弃了它们——include 自身"dump 永不偏离实际启动"的契约,被一个 dump 看不到的补丁打破。外部报告 discussion #3636 给出了准确的根因。 + +该 overlay 还位于 `ComposedProfile.overlays`——热重载在新鲜用户层之上重放的固定顶层。overlay 的存在意义是让用户编辑无法顶掉启动器事实,这对 `--patch` 文件和遥测开关是正确的——但 roster 补丁快照了启动时的整个 `config`,导致启动后对该行的任何 `cordis.patch.yml` 编辑(`default`、`includeUserRoot`、`roots`)在重启前都不生效。 + +## 决定 + +内置根是一个派生,不是一个 overlay。`resolveShippedPresetPatch(rows)` 从一份已组合的行集构建 roster 补丁:保留全部已配置的键,并把内置根(`system` 信任)前置到组合的 `roots` 中,因此内置 preset 始终挂载并在 id 冲突时胜出,而配置的根目录保持生效。`composeProfilePatches(layers)` 把该补丁追加到展平后的补丁栈,是启动、用户层热重载与配置 dump 共同经过的唯一构建器——热重载从当前用户层派生而非重放启动快照,dump 也渲染这个派生层(标注为 `dsh launcher (shipped agent-preset root)`),使 roster 行的组合与实际启动完全一致。遥测开关仍是仅启动时的 overlay:它是启动进程的环境事实,不携带 config 快照,压过用户编辑正是其目的。 + +启动器无法静态改写的 `roots` 值——`!!js` 表达式或任何非数组——现在以指明约束的 `TypeError` 大声失败,而不是被静默替换。插件自身的契约不变:`config.roots` 按序扫描,可写 home 根由 `dsh-agent-presets` 自己追加。 + +## 测试 + +`shipped-preset-root.spec.ts` 直接覆盖派生逻辑:前置顺序、键保留、无 roster 行时不产出、逐次调用派生、大声失败的拒绝分支,以及经完整 `composeEntries` 应用验证的压掉回归。Web 组合 e2e 现在通过真实的 `composeProfilePatches` 获得内置根,不再手抄启动器补丁(此前三处启动逐字复制了它,其中一处自述"exactly what `composeProfile` supplies"),并新增配置根目录的启动场景:共享根的 preset 与内置四个并列出现、占用内置 id 的目录被其遮蔽、配置根中的 preset 能组合出 agent。built-bin dump 验收断言派生层标签及"内置根在配置根之前"的顺序。无 keyless 快照变更:默认组合产生的补丁栈逐字节相同,且快照框架没有自定义 profile 通道——真实组合 e2e 即是组装应用层面的证据。 + +## 曾考虑的替代方案 + +**报告者的修法:在启动时 overlay 内部做前置。** 对压掉问题与优先级顺序判断正确,派生补丁保留了这一形状。按原样采纳被否,因为该 overlay 仍会把启动时的整个 `config` 冻结在所有后续重载之上,该行的实时编辑在重启前依然失效。 + +**带外提供内置根(启动器提供的上下文值,由插件前置)。** 热重载故事最干净——完全不改写 config——但它把装配事实挪进插件的服务契约,给一个本只读 config 的包加上与启动器耦合的 provide 键,还让有效根目录对配置 dump 不可见。派生补丁把 roster 的输入完整留在组合之内。 + +## 后果 + +配置的 preset 根目录在启动后存活,对 roster 行的实时编辑无需重启即生效,dump、活动树与启动对该行的组合完全一致。启动器将 roster 行的 `config`/`roots` 约束为字面量;此前用 `!!js` 生成它们的组合本来就会被静默丢弃表达式,现在必须在某个补丁层实体化该数组。 diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 1754eb4efd..229a4c67ab 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -2,7 +2,8 @@ * Config-dump entry for `dsh --profile --dump-config`: compose the * profile's patch layers through the include plugin's patch algorithm without * booting or evaluating `!!js`, with one source layer per bundle, the - * profile's own patch file, and each `--patch` overlay. + * profile's own patch file, each `--patch` overlay, and the launcher-derived + * shipped agent-preset root. * @module @deepseek-ai/dsh/dump-config */ @@ -14,7 +15,7 @@ import { renderConfigDump, type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' -import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' +import { composeRows, homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME, resolveShippedPresetPatch } from './profile-boot.ts' const NAME = 'dsh' @@ -47,6 +48,15 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) }) } } + // The launcher derives one more layer no file carries: the shipped + // agent-preset root, prepended to whatever roots the layers configured. + // Included so the dump composes the roster row exactly as it boots. (The + // telemetry hard-disable switch stays out: it is an environment fact of the + // booting process, not part of the profile composition.) + const presetPatch = resolveShippedPresetPatch(composeRows(layers.map(layer => layer.patches))) + if (presetPatch !== undefined) { + layers.push({ label: `${NAME} launcher (shipped agent-preset root)`, patches: [presetPatch] }) + } // The dump anchors on the same empty root file the boot includes. process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers)) } diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 19c4abb245..bdb11462bd 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -16,7 +16,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -120,12 +120,74 @@ interface ComposedProfile { /** The full patch stack of one composed profile, in application order. */ function allPatches(composed: ComposedProfile): PatchOptions[] { - return [ - ...composed.bundlePatches, - ...composed.profile.patches, - ...composed.homePatches, - ...composed.overlays, - ] + return composeProfilePatches([ + composed.bundlePatches, + composed.profile.patches, + composed.homePatches, + composed.overlays, + ]) +} + +/** + * Compose patch layers and index the resulting rows by id. + * @param layers - patch lists in application order. + * @returns id → composed row, for rows that carry a string id. + */ +export function composeRows(layers: readonly PatchOptions[][]): Map { + const rows = new Map() + for (const row of composeEntries(layers)) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + return rows +} + +/** + * Derive the shipped agent-preset-root patch from one composed row set. The + * shipped root is the part of the roster only this app can resolve: it sits + * beside this app's own config, in both the source and built layouts. The + * derived patch keeps every configured key and PREPENDS the shipped root to + * the composition's `roots`, so the shipped presets always mount and win a + * duplicate id while configured roots stay live. (The writable root the + * roster appends is `dsh-agent-presets`' own, so a launcher that never + * reaches this patch still finds a person's presets.) + * @param rows - id → row of the composed tree the patch applies over. + * @returns the roster patch, or `undefined` when the composition has no roster row. + * @throws TypeError when the composed row's config or its `roots` is not a + * literal the launcher can rewrite (a `!!js` expression or a non-array value). + */ +export function resolveShippedPresetPatch(rows: ReadonlyMap): PatchOptions | undefined { + const row = rows.get('agent-presets') + if (row === undefined) return undefined + const config: unknown = row.config ?? {} + if (typeof config !== 'object' || config === null || Array.isArray(config) || isJsExpr(config)) { + throw new TypeError(`${NAME}: agent-presets config must be a literal mapping — the launcher prepends the shipped preset root into it`) + } + const configured = (config as Record).roots ?? [] + if (!Array.isArray(configured)) { + throw new TypeError(`${NAME}: agent-presets config.roots must be a literal array — the launcher prepends the shipped preset root into it`) + } + const configuredRoots: readonly unknown[] = configured + return { + id: 'agent-presets', + config: { + ...(config as Record), + roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }, ...configuredRoots], + }, + } +} + +/** + * Compose one generation's full patch stack: the layers in application order, + * then the shipped preset-root patch derived from their composition. Shared + * by boot and the live user-layer reloads, so a reload derives the roster + * from the CURRENT user layers instead of replaying a boot-time snapshot — + * an edit to the row's config, `roots` included, keeps taking effect. + * @param layers - patch lists in application order. + * @returns the flattened stack with the derived roster patch appended. + */ +export function composeProfilePatches(layers: readonly PatchOptions[][]): PatchOptions[] { + const presetPatch = resolveShippedPresetPatch(composeRows(layers)) + return [...layers.flat(), ...presetPatch === undefined ? [] : [presetPatch]] } /** @@ -147,24 +209,11 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) - const rows = new Map() - for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { - if (typeof row.id === 'string') rows.set(row.id, row) - } + const rows = composeRows([bundlePatches, profile.patches, homePatches, overlays]) + // The shipped agent-preset root is NOT pushed here: it is derived from the + // current layers on every composition (`composeProfilePatches`), so a live + // user-layer edit to the roster row keeps taking effect. const composedOverlays = [...overlays] - // The SHIPPED root is the part of the roster only this app can resolve: it - // sits beside this app's own config, in both the source and built layouts. - // The writable root the roster appends is `dsh-agent-presets`' own, so a - // launcher that never reaches this patch still finds a person's presets. - if (rows.has('agent-presets')) { - composedOverlays.push({ - id: 'agent-presets', - config: { - ...(rows.get('agent-presets')?.config ?? {}) as Record, - roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }], - }, - }) - } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows } @@ -237,12 +286,14 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // objects in place. Reusing one parsed patch object across applications // would bake a user override into the bundle's in-memory insert row, so // removing the override could never revert the row to the bundle default. - const composeLive = (): PatchOptions[] => structuredClone([ - ...composed.bundlePatches, - ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], - ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ...composed.overlays, - ]) + // The derived shipped-preset patch is recomputed per generation from these + // fresh layers, never carried over from boot. + const composeLive = (): PatchOptions[] => structuredClone(composeProfilePatches([ + composed.bundlePatches, + loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], + loadOptionalPatches(NAME, homePatchPath()) ?? [], + composed.overlays, + ])) // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 75ab640fcc..a2c4fa97c0 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -748,6 +748,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ' - id: personal', ' provider: personal-provider', ' model: personal-model', + '- id: agent-presets', + ' config:', + ' default: standard', + ' roots:', + ` - path: ${join(home, 'team-presets')}`, + ' trust: user', '- id: absent-row', ' config:', ' x: 1', @@ -772,6 +778,13 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toContain('personal-provider') // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) + // The dump composes the launcher-derived roster layer too: the shipped + // preset root is prepended to the user layer's roots, not replacing them. + expect(stdout).toContain('dsh launcher (shipped agent-preset root)') + const shippedRootAt = stdout.search(/config[\\/]+agent-presets/) + const configuredRootAt = stdout.search(/team-presets/) + expect(shippedRootAt).toBeGreaterThanOrEqual(0) + expect(configuredRootAt).toBeGreaterThan(shippedRootAt) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) }) diff --git a/apps/cli/tests/shipped-preset-root.spec.ts b/apps/cli/tests/shipped-preset-root.spec.ts new file mode 100644 index 0000000000..3f43daa96c --- /dev/null +++ b/apps/cli/tests/shipped-preset-root.spec.ts @@ -0,0 +1,89 @@ +import { sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { composeEntries } from '@deepseek-ai/dsh-app-boot' +import { composeProfilePatches, composeRows, resolveShippedPresetPatch } from '../src/profile-boot.ts' + +/** The web bundle's roster insert, reduced to the keys the derivation reads. */ +const bundleLayer: PatchOptions[] = [{ + insert: [{ id: 'agent-presets', name: '@deepseek-ai/dsh-agent-presets', config: { default: 'standard' } }], +}] + +const userLayer = (config: Record): PatchOptions[] => [{ id: 'agent-presets', config }] + +const shippedRoot = { path: expect.stringContaining(`config${sep}agent-presets`) as unknown, trust: 'system' } + +/** Apply a full patch stack the way boot does and return the roster row's mounted config. */ +function finalRosterConfig(patches: PatchOptions[]): Record { + const row = composeEntries([patches]).find(entry => entry.id === 'agent-presets') + if (row === undefined) throw new Error('missing agent-presets row') + return row.config as Record +} + +describe('resolveShippedPresetPatch', () => { + it('is absent for a composition without the roster row', () => { + const rows = composeRows([[{ insert: [{ id: 'other', name: '@deepseek-ai/dsh-other' }] }]]) + expect(resolveShippedPresetPatch(rows)).toBeUndefined() + }) + + it('prepends the shipped root to configured roots and preserves every other key', () => { + const rows = composeRows([bundleLayer, userLayer({ + default: 'minimal', + roots: [{ path: `${sep}shared${sep}presets`, trust: 'user' }], + includeUserRoot: false, + })]) + expect(resolveShippedPresetPatch(rows)).toEqual({ + id: 'agent-presets', + config: { + default: 'minimal', + includeUserRoot: false, + roots: [shippedRoot, { path: `${sep}shared${sep}presets`, trust: 'user' }], + }, + }) + }) + + it('supplies the shipped root alone when the composition configures none', () => { + const patch = resolveShippedPresetPatch(composeRows([bundleLayer])) + expect(patch).toEqual({ id: 'agent-presets', config: { default: 'standard', roots: [shippedRoot] } }) + }) + + it('fails loud on a config it cannot statically rewrite', () => { + expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, userLayer({ default: 'standard', roots: 'nope' })]))) + .toThrow(TypeError) + expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, userLayer({ default: 'standard', roots: { __jsExpr: 'x' } })]))) + .toThrow(/literal array/) + expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, [{ id: 'agent-presets', config: { __jsExpr: 'x' } }]]))) + .toThrow(/literal mapping/) + }) +}) + +describe('composeProfilePatches', () => { + it('keeps configured roots effective through the whole patch application', () => { + // The squash this stack exists to prevent: the derived patch must extend + // the user layer's roots, not replace them with the shipped root. + const config = finalRosterConfig(composeProfilePatches([bundleLayer, userLayer({ + default: 'standard', + roots: [{ path: `${sep}shared${sep}presets`, trust: 'user' }], + includeUserRoot: true, + })])) + expect(config.roots).toEqual([shippedRoot, { path: `${sep}shared${sep}presets`, trust: 'user' }]) + expect(config.default).toBe('standard') + expect(config.includeUserRoot).toBe(true) + }) + + it('derives from the layers each call is given, not from an earlier composition', () => { + // The live user-layer reload calls this per generation: an edited + // cordis.patch.yml must decide the derived roots, never a boot snapshot. + composeProfilePatches([bundleLayer, userLayer({ default: 'standard', roots: [{ path: `${sep}one`, trust: 'user' }] })]) + const config = finalRosterConfig(composeProfilePatches([bundleLayer, userLayer({ + default: 'standard', + roots: [{ path: `${sep}two`, trust: 'user' }], + })])) + expect(config.roots).toEqual([shippedRoot, { path: `${sep}two`, trust: 'user' }]) + }) + + it('appends nothing to a composition without the roster row', () => { + const layers = [[{ insert: [{ id: 'other', name: '@deepseek-ai/dsh-other' }] }]] + expect(composeProfilePatches(layers)).toEqual(layers.flat()) + }) +}) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 0e98af0477..6994cac956 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -12,6 +12,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { composeProfilePatches } from '../src/profile-boot.ts' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' @@ -95,18 +96,12 @@ async function bootWeb( { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }, { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' }, ] }, - // The roster AppCLIEntry would patch in; only the shipped root, so a - // developer's own `~/.dsh/.preset` cannot change this test's outcome. + // Pin the roster away from the developer's machine: `includeUserRoot` + // false keeps `~/.dsh/.agent-presets` from changing a test's outcome. // `default` here is the COMPOSITION default — the base layer the settings - // document overrides. - { - id: 'agent-presets', - config: { - default: 'standard', - roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], - includeUserRoot: false, - }, - }, + // document overrides. No `roots` entry: the launcher's real derivation + // below prepends the shipped root, exactly as `runProfile` composes it. + { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, ...extra, ] // The surface is patch layers over an empty preset root, so the root sits @@ -142,7 +137,10 @@ async function bootWeb( } const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => { + // The shipped preset root arrives the way the real launcher delivers it: + // derived over these same layers, appended after every override. + const patches = composeProfilePatches([bundlePatches, overrides]) + return await boot('dsh-test', rootConfig, patches, (bootCtx) => { provideCmdline(bootCtx, { args: [], exit: () => {} }) }) } @@ -492,10 +490,8 @@ describe('product Bundle and user-preset intersection', () => { id: 'agent-presets', config: { default: 'standard', - roots: [ - { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, - { path: userRoot, trust: 'user' }, - ], + // The shipped root is bootWeb's derivation, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, }, @@ -730,15 +726,11 @@ describe('a launcher that configures no writable root', () => { ) const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') await writeFile(settingsFile, '{}\n') - // Only the shipped root, exactly what `composeProfile` supplies; the + // No configured roots: the shipped one is bootWeb's derivation, and the // writable one is the roster's own default rather than this patch's job. derivedCtx = await bootWeb(settingsFile, [{ id: 'agent-presets', - config: { - default: 'standard', - roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], - includeUserRoot: true, - }, + config: { default: 'standard', includeUserRoot: true }, }]) }, 120_000) @@ -781,12 +773,10 @@ describe('authoring a preset on the shipped composition', () => { id: 'agent-presets', config: { default: 'standard', - roots: [ - { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, - // The root does not exist yet: a deployment whose user has authored - // nothing is the normal first-run state. - { path: userRoot, trust: 'user' }, - ], + // The root does not exist yet: a deployment whose user has authored + // nothing is the normal first-run state. The shipped root is bootWeb's + // derivation, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, }]) @@ -893,3 +883,64 @@ describe('a session keeps the preset it was created with', () => { } }) }) + +describe('a composition that configures its own preset roots', () => { + let rootsCtx: Context + let teamRoot: string + + beforeAll(async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-roots-')) + const settingsFile = join(home, 'settings.yaml') + await writeFile(settingsFile, '{}\n') + // A workspace-shared root beside the deployment: one preset of its own, + // plus a directory that claims a shipped id. + teamRoot = join(home, 'team-presets') + const minimalComposition = await readFile(join(CONFIG_DIR, 'agent-presets', 'minimal', 'agent.cordis.yml'), 'utf8') + for (const id of ['team-spec', 'minimal']) { + await mkdir(join(teamRoot, id), { recursive: true }) + await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition) + } + // The user layer of the reported regression: a profile's cordis.patch.yml + // configuring a shared preset root. The derivation must EXTEND it with + // the shipped root, never replace it. + rootsCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [{ path: teamRoot, trust: 'user' }], + includeUserRoot: false, + }, + }]) + }, 120_000) + + afterAll(async () => { + await rootsCtx.fiber.dispose() + }) + + it('keeps configured roots alongside the always-prepended shipped root', async () => { + expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([ + expect.stringContaining(join('config', 'agent-presets')), + teamRoot, + ]) + + const listed = await rootsCtx.agentPresets.list() + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard', 'team-spec']) + expect(listed.every(preset => preset.broken === undefined)).toBe(true) + // The shipped root comes first: a configured directory claiming a shipped + // id is shadowed, never the other way around. + expect(listed.find(preset => preset.id === 'minimal')?.trust).toBe('system') + expect(listed.find(preset => preset.id === 'team-spec')?.trust).toBe('user') + }) + + it('composes an agent from a configured-root preset', async () => { + const handle = await rootsCtx.agents.create({ + sessionId: SessionId('preset-team-spec'), + setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined), + }) + try { + expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) +}) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 61151bdc65..10f826a320 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -434,10 +434,10 @@ # as shell access because a preset IS a composition. # # Only the SHIPPED root is an assembly fact: it sits beside the installed app's -# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the -# same treatment `distIndex` gets on the webserver row. The writable root is -# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that -# never reaches that patch still finds a person's presets. +# own config, so `apps/cli`'s launcher derives a patch per composition that +# PREPENDS it to whatever `roots` the user layers configured here. The writable +# root is `dsh-agent-presets`' own default (`includeUserRoot`), so a +# composition that never reaches that patch still finds a person's presets. - insert: - id: agent-presets name: '@deepseek-ai/dsh-agent-presets' From 25058f2658037eb5fa991a71f7f160201c0ed06e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 21 Aug 2026 11:32:02 +0800 Subject: [PATCH 002/138] docs(cli): sync the derived preset-root layer into launcher docs Review follow-ups: enumerate the derived shipped agent-preset root in apps/cli README/reference dumps and the profile-boot module JSDoc (bilingual pairs re-recorded), correct the stale AppCLIEntry/distIndex analogy in the web scaffold and the shipped-root cross-reference in the web preset e2e, drop the write-only ComposedProfile.rows field, and make the Agent Note describe the dump path as sharing the derivation rather than the builder. --- ...ipped-preset-root-per-composition.i18n.yaml | 4 ++-- ...rive-shipped-preset-root-per-composition.md | 2 +- ...e-shipped-preset-root-per-composition.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 1 + apps/cli/README.zh.md | 1 + apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/profile-boot.ts | 18 +++++++----------- apps/cli/tests/web-agent-presets.e2e.ts | 4 ++-- apps/web/tests/scaffold.ts | 17 +++++++++-------- 12 files changed, 30 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml index f5c3964d7a..26aa999982 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.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-20-derive-shipped-preset-root-per-composition.md -2026-08-20-derive-shipped-preset-root-per-composition.md: b303f6a5d08ac2c2ca755d5dbf46eb9a74f5c4ee -2026-08-20-derive-shipped-preset-root-per-composition.zh.md: cc28789898a74df285a96b7e17c35e3b4d12c452 +2026-08-20-derive-shipped-preset-root-per-composition.md: ecf852ade3720cbf5f5f99efa073cc6e3a352fec +2026-08-20-derive-shipped-preset-root-per-composition.zh.md: 718bddd6e4159b32db63262bb40a1e0ce38227ac diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md index b303f6a5d0..ecf852ade3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md @@ -12,7 +12,7 @@ The overlay also sat in `ComposedProfile.overlays`, the fixed top layers a live ## Decision -The shipped root is a derivation, not an overlay. `resolveShippedPresetPatch(rows)` builds the roster patch from one composed row set: it keeps every configured key and prepends the shipped root (`system` trust) to the composition's `roots`, so the shipped presets always mount and win a duplicate id while configured roots stay live. `composeProfilePatches(layers)` appends that patch to the flattened stack and is the one builder boot, the live user-layer reloads, and the config dump all go through — a reload derives from the current user layers instead of replaying a boot snapshot, and the dump now renders the derived layer (labeled `dsh launcher (shipped agent-preset root)`) so it composes the roster row exactly as it boots. The telemetry switch stays a boot-only overlay: it is an environment fact of the booting process, carries no config snapshot, and outranking user edits is its purpose. +The shipped root is a derivation, not an overlay. `resolveShippedPresetPatch(rows)` builds the roster patch from one composed row set: it keeps every configured key and prepends the shipped root (`system` trust) to the composition's `roots`, so the shipped presets always mount and win a duplicate id while configured roots stay live. `composeProfilePatches(layers)` appends that patch to the flattened stack and is the builder boot and the live user-layer reloads share — a reload derives from the current user layers instead of replaying a boot snapshot. The config dump shares the derivation rather than the builder: `renderConfigDump` needs one labeled layer per source, so `runDumpConfig` appends `resolveShippedPresetPatch`'s output as its own layer (labeled `dsh launcher (shipped agent-preset root)`) and composes the roster row exactly as it boots. The telemetry switch stays a boot-only overlay: it is an environment fact of the booting process, carries no config snapshot, and outranking user edits is its purpose. A `roots` value the launcher cannot statically rewrite — a `!!js` expression or any non-array — now fails loud with a `TypeError` naming the constraint, instead of being silently replaced. The plugin's own contract is untouched: `config.roots` scanned in order, the writable home root appended by `dsh-agent-presets` itself. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md index cc28789898..718bddd6e4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -内置根是一个派生,不是一个 overlay。`resolveShippedPresetPatch(rows)` 从一份已组合的行集构建 roster 补丁:保留全部已配置的键,并把内置根(`system` 信任)前置到组合的 `roots` 中,因此内置 preset 始终挂载并在 id 冲突时胜出,而配置的根目录保持生效。`composeProfilePatches(layers)` 把该补丁追加到展平后的补丁栈,是启动、用户层热重载与配置 dump 共同经过的唯一构建器——热重载从当前用户层派生而非重放启动快照,dump 也渲染这个派生层(标注为 `dsh launcher (shipped agent-preset root)`),使 roster 行的组合与实际启动完全一致。遥测开关仍是仅启动时的 overlay:它是启动进程的环境事实,不携带 config 快照,压过用户编辑正是其目的。 +内置根是一个派生,不是一个 overlay。`resolveShippedPresetPatch(rows)` 从一份已组合的行集构建 roster 补丁:保留全部已配置的键,并把内置根(`system` 信任)前置到组合的 `roots` 中,因此内置 preset 始终挂载并在 id 冲突时胜出,而配置的根目录保持生效。`composeProfilePatches(layers)` 把该补丁追加到展平后的补丁栈,是启动与用户层热重载共用的构建器——热重载从当前用户层派生而非重放启动快照。配置 dump 共用的是派生本身而非构建器:`renderConfigDump` 需要逐层标注来源,所以 `runDumpConfig` 把 `resolveShippedPresetPatch` 的输出作为独立一层追加(标注为 `dsh launcher (shipped agent-preset root)`),对 roster 行的组合与实际启动完全一致。遥测开关仍是仅启动时的 overlay:它是启动进程的环境事实,不携带 config 快照,压过用户编辑正是其目的。 启动器无法静态改写的 `roots` 值——`!!js` 表达式或任何非数组——现在以指明约束的 `TypeError` 大声失败,而不是被静默替换。插件自身的契约不变:`config.roots` 按序扫描,可写 home 根由 `dsh-agent-presets` 自己追加。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index fbea2bc740..c33c75f2cb 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 9a8d722b044ed5d8e31e3c27e54f8c9ef0839f82 -README.zh.md: c092414e2d15e90133d8ea27f0af5cadbe527b22 +README.md: ae6f4c38eee402bcc1a86ba34778afd06da749b1 +README.zh.md: d4661476e4310b987b22fec7a7d417b9ea6811ab diff --git a/apps/cli/README.md b/apps/cli/README.md index 9a8d722b04..ae6f4c38ee 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -35,6 +35,7 @@ The tree composes over an empty root: - each bundle's patch in `dsh.profile.bundles` order - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml` - then `--patch` overlays +- then, when the composition mounts the preset roster, a launcher-derived patch that prepends the shipped agent-preset root to the configured `roots` Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index c092414e2d..d4661476e4 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -37,6 +37,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 - `dsh.profile.bundles` 中各组合包的 patch - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml` - `--patch` 指定的覆盖层 +- 组合挂载预设 roster 时,启动器再派生一个补丁,把内置 agent-preset 根目录前置到已配置的 `roots` 之前 `dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 117c2c6aac..0d5bb37319 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: dfddd177a78c348793d3e5c2d290fa62c5ac850b -README.zh.md: 8e7508b4b8fcbd39e15538c6ee88733bfa9905f1 +README.md: 2e8c262f5c1e7045472eaaa53657e9b9a7bab9a4 +README.zh.md: 27a63337b6166d3aa560a2205912575a007b0c72 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index dfddd177a7..2e8c262f5c 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. When the composition mounts the preset roster, both also append the launcher-derived `dsh launcher (shipped agent-preset root)` layer, so the dump composes that row exactly as boot does. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 8e7508b4b8..27a63337b6 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 +`--dump-default-config` 打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。当组合挂载预设 roster 时,两者还会追加启动器派生的 `dsh launcher (shipped agent-preset root)` 层,因此 dump 对该行的组合与实际启动完全一致。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 ## 插件管理 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index bdb11462bd..68ce0d9749 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,9 +1,10 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's - * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the - * tree over the profile's empty root config, keep the profile patch layer - * live, and wire fail-loud plus bounded shutdown. + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch, and the + * per-composition derived shipped agent-preset root), mount the tree over the + * profile's empty root config, keep the profile patch layer live, and wire + * fail-loud plus bounded shutdown. * * App flags are not the launcher's business: the invocation's inner arguments * are provided to the tree through `ctx.cmdlineArgs`, where any injected app @@ -102,7 +103,7 @@ export function prepareProfile(name: string, userLayer = true): Profile { return profile } -/** One profile's patch layers (application order) and the row index of its pre-flag composition. */ +/** One profile's patch layers, in application order. */ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ @@ -111,11 +112,6 @@ interface ComposedProfile { homePatches: PatchOptions[] /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ overlays: PatchOptions[] - /** - * id → row of the composed tree (bundles + user layers + overlays), for the - * launcher's own row checks. - */ - rows: ReadonlyMap } /** The full patch stack of one composed profile, in application order. */ @@ -199,7 +195,7 @@ export function composeProfilePatches(layers: readonly PatchOptions[][]): PatchO * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. - * @returns the profile, its patch layers, and the composed row index. + * @returns the profile and its patch layers. */ function composeProfile( name: string, @@ -216,7 +212,7 @@ function composeProfile( const composedOverlays = [...overlays] const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) - return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows } + return { profile, bundlePatches, homePatches, overlays: composedOverlays } } /** Options for {@link runProfile}. */ diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 6994cac956..f448586279 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -57,8 +57,8 @@ async function bootWeb( // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it // reads the developer's own document — and since the default preset is a // setting, a stored `agent-presets.default` would decide this file's - // outcome. Point it at a temp file for the same reason the roster below - // names only the shipped root. + // outcome. Point it at a temp file for the same reason the roster row + // below pins `includeUserRoot` off. { id: 'settings', config: { path: settingsFile, watch: false } }, // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this // file writes the developer's own `~/.dsh/storages/` — and then reads it diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 83334e13d3..6ecef055ce 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -401,14 +401,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Fri, 21 Aug 2026 12:37:57 +0800 Subject: [PATCH 003/138] refactor(preset): bundle the shipped presets inside dsh-agent-presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked why the launcher special-cases one plugin's row. It no longer does: the four shipped compositions move into the package (presets/, in files), dsh-agent-presets resolves its own shipped root and prepends it before configured roots (includeShippedRoot, default true, opt-out for bare-machinery embedders), and the per-composition derived patch, its spec, and the dump layer are deleted — profile-boot and dump-config return to plain layer stacking. The always-load guarantee now rides the schema default instead of patch ordering, so a whole-config replacement keeps the shipped set and the squash, reload freeze, and dump divergence stop being possible. Gate globs, the web scaffold, and both preset browser lanes drop their hand-fed shipped roots; the roster e2e keeps asserting configured roots beside the shipped four against the built lib. Fixes #2863. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...ive-shipped-preset-root-per-composition.md | 31 ----- ...-shipped-preset-root-per-composition.zh.md | 31 ----- ...lugin-owned-shipped-preset-root.i18n.yaml} | 6 +- ...-08-20-plugin-owned-shipped-preset-root.md | 33 ++++++ ...-20-plugin-owned-shipped-preset-root.zh.md | 33 ++++++ ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 1 - apps/cli/README.zh.md | 1 - apps/cli/package.json | 3 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/dump-config.ts | 14 +-- apps/cli/src/profile-boot.ts | 109 ++++-------------- apps/cli/tests/built-bin.e2e.ts | 13 --- apps/cli/tests/shipped-preset-root.spec.ts | 89 -------------- apps/cli/tests/web-agent-presets.e2e.ts | 35 +++--- apps/cli/tests/windows-shell.spec.ts | 5 +- apps/web/tests/agent-preset-authoring.e2e.ts | 10 +- apps/web/tests/agent-preset-selection.e2e.ts | 14 +-- apps/web/tests/scaffold.ts | 29 ++--- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 10 +- examples/acp-agent/tests/acp.snapshot.ts | 2 +- packages/bundle/web-app/cordis.patch.yml | 17 ++- packages/preset/README.i18n.yaml | 4 +- packages/preset/README.md | 2 +- packages/preset/README.zh.md | 2 +- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 7 +- packages/preset/agent-presets/README.zh.md | 7 +- packages/preset/agent-presets/package.json | 3 +- .../presets}/code/agent.cordis.yml | 0 .../agent-presets/presets}/code/preset.yml | 0 .../presets}/cordis/agent.cordis.yml | 0 .../agent-presets/presets}/cordis/preset.yml | 0 .../skills/cordis-plugin-development/SKILL.md | 0 .../editing-cordis-compositions/SKILL.md | 0 .../presets}/minimal/agent.cordis.yml | 0 .../agent-presets/presets}/minimal/preset.yml | 0 .../presets}/standard/agent.cordis.yml | 0 .../presets}/standard/preset.yml | 0 .../preset/agent-presets/src/discovery.ts | 17 ++- packages/preset/agent-presets/src/index.ts | 32 ++--- packages/preset/agent-presets/src/preset.ts | 10 +- .../agent-presets/tests/authoring.spec.ts | 11 +- .../agent-presets/tests/invariant.spec.ts | 2 +- .../preset/agent-presets/tests/mount.spec.ts | 16 +-- .../agent-presets/tests/settings.spec.ts | 2 +- .../agent-presets/tests/shipped-root.spec.ts | 90 +++++++++++++++ .../agent-presets/tests/user-root.spec.ts | 2 + .../tests/preset-inheritance.spec.ts | 2 +- scripts/rescope-vendor.ts | 8 +- scripts/verify-cordis-config.ts | 2 +- scripts/verify-runtime-closure.spec.ts | 14 +-- scripts/verify-runtime-closure.ts | 2 +- 66 files changed, 358 insertions(+), 417 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md rename .agents/notes/implemented/bug-fix/{2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml => 2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml} (52%) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md delete mode 100644 apps/cli/tests/shipped-preset-root.spec.ts rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/code/agent.cordis.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/code/preset.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/cordis/agent.cordis.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/cordis/preset.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/cordis/skills/cordis-plugin-development/SKILL.md (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/cordis/skills/editing-cordis-compositions/SKILL.md (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/minimal/agent.cordis.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/minimal/preset.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/standard/agent.cordis.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/standard/preset.yml (100%) create mode 100644 packages/preset/agent-presets/tests/shipped-root.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index b8bcc4246e..02e3efff43 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab +2026-07-10-single-file-executable-sdk-runtime-distribution.md: cc62ed280ff354073bab10646bdbf8331a81bc06 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 89f6ab43315ba22f6ff09368f442424284ae9ee9 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 40433d99e5..cc62ed280f 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index cff72ae10e..89f6ab4331 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,7 +36,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 6d956d86ec..1d6c5dcae9 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 5a82f0220058c10892b819a83499c817aa9be6ad -2026-08-03-per-session-agent-presets.zh.md: 0adcfec8b2c39a1f97d74edfa784f45062b52a99 +2026-08-03-per-session-agent-presets.md: f21620b9cd67bbb9f73317c3ddaa4926331393cc +2026-08-03-per-session-agent-presets.zh.md: 9d58a235bd55e88644b0e59a3a5c92863469b583 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 5a82f02200..f21620b9cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -23,7 +23,7 @@ Composition splits into two planes, decided by what must be shared rather than b Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. -The presets the deployment ships are the directories under `apps/cli/config/agent-presets/`; the roster is that listing, not a list restated here. +The presets the deployment ships are the directories under `packages/preset/agent-presets/presets/`; the roster is that listing, not a list restated here. Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 0adcfec8b2..9d58a235bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -23,7 +23,7 @@ Status: implemented 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 -部署交付哪些 preset,取决于 `apps/cli/config/agent-presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。 +部署交付哪些 preset,取决于 `packages/preset/agent-presets/presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md deleted file mode 100644 index ecf852ade3..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Derive the shipped preset root per composition - -Status: implemented - -English | [中文](2026-08-20-derive-shipped-preset-root-per-composition.zh.md) - -## Problem - -`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a shared preset directory booted with only the shipped root plus the roster's own writable home root, and every custom preset vanished from the Web picker. `dsh --dump-config` composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them — the include's own contract that a dump can never drift from what boots was broken by a patch the dump never saw. Externally reported with an accurate root cause in discussion #3636. - -The overlay also sat in `ComposedProfile.overlays`, the fixed top layers a live reload replays above fresh user layers. Overlays exist so a user edit cannot displace launcher facts, which is right for `--patch` files and the telemetry switch — but the roster patch had captured the whole boot-time `config`, so after boot no `cordis.patch.yml` edit to the row (`default`, `includeUserRoot`, `roots`) could take effect until restart. - -## Decision - -The shipped root is a derivation, not an overlay. `resolveShippedPresetPatch(rows)` builds the roster patch from one composed row set: it keeps every configured key and prepends the shipped root (`system` trust) to the composition's `roots`, so the shipped presets always mount and win a duplicate id while configured roots stay live. `composeProfilePatches(layers)` appends that patch to the flattened stack and is the builder boot and the live user-layer reloads share — a reload derives from the current user layers instead of replaying a boot snapshot. The config dump shares the derivation rather than the builder: `renderConfigDump` needs one labeled layer per source, so `runDumpConfig` appends `resolveShippedPresetPatch`'s output as its own layer (labeled `dsh launcher (shipped agent-preset root)`) and composes the roster row exactly as it boots. The telemetry switch stays a boot-only overlay: it is an environment fact of the booting process, carries no config snapshot, and outranking user edits is its purpose. - -A `roots` value the launcher cannot statically rewrite — a `!!js` expression or any non-array — now fails loud with a `TypeError` naming the constraint, instead of being silently replaced. The plugin's own contract is untouched: `config.roots` scanned in order, the writable home root appended by `dsh-agent-presets` itself. - -## Testing - -`shipped-preset-root.spec.ts` covers the derivation directly: prepend order, key preservation, absence without a roster row, per-call derivation, the fail-loud rejections, and the squash regression through a full `composeEntries` application. The Web composition e2e now obtains the shipped root through the real `composeProfilePatches` instead of hand-writing the launcher's patch (three boots had replicated it literally, one admitting "exactly what `composeProfile` supplies"), and adds a configured-roots boot: a shared root's preset lists beside the shipped four, a directory claiming a shipped id is shadowed by it, and a configured-root preset composes an agent. The built-bin dump acceptance asserts the derived layer's label and the shipped-before-configured root order. No keyless snapshot changes: default compositions produce byte-identical stacks, and the snapshot harness has no custom-profile lane — the real-composition e2e is the assembled-application evidence here. - -## Alternatives considered - -**The reporter's fix: prepend inside the boot-time overlay.** Correct on the squash and the priority order, and kept as the shape of the derived patch. Rejected as-is because the overlay would still freeze the whole boot-time `config` above every later reload, leaving the row's live edits dead until restart. - -**Provide the shipped root out of band (a launcher-provided context value the plugin prepends).** Cleanest hot-reload story — no config rewriting at all — but it moves an assembly fact into the plugin's service contract, adds a launcher-coupled provide key to a package that otherwise only reads config, and makes the effective roots invisible to the config dump. The derived patch keeps the roster's inputs entirely in the composition. - -## Consequences - -Configured preset roots survive boot, live edits to the roster row take effect without restart, and the dump, the live tree, and the boot compose the row identically. The launcher constrains the roster row's `config`/`roots` to literal values; a composition that generated them with `!!js` would previously have had the expression silently discarded and now must materialize the array in a patch layer instead. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md deleted file mode 100644 index 718bddd6e4..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Derive the shipped preset root per composition - -Status: implemented - -[English](2026-08-20-derive-shipped-preset-root-per-composition.md) | 中文 - -## 问题 - -`composeProfile` 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 `config` 展开已组合的 roster 行后,把 `roots` 硬设为仅含内置根。由于 id 定向补丁会整体替换 `config` 值,这个 overlay 压掉了 profile 的 `cordis.patch.yml`(以及 home 层、`--patch` overlay)配置的全部根目录:把 `agent-presets` 指向共享 preset 目录的部署,启动后只剩内置根加 roster 自己的可写 home 根,所有自定义 preset 从 Web 选择器中消失。`dsh --dump-config` 只组合文件承载的层,所以 dump 显示配置的根目录完好而启动却丢弃了它们——include 自身"dump 永不偏离实际启动"的契约,被一个 dump 看不到的补丁打破。外部报告 discussion #3636 给出了准确的根因。 - -该 overlay 还位于 `ComposedProfile.overlays`——热重载在新鲜用户层之上重放的固定顶层。overlay 的存在意义是让用户编辑无法顶掉启动器事实,这对 `--patch` 文件和遥测开关是正确的——但 roster 补丁快照了启动时的整个 `config`,导致启动后对该行的任何 `cordis.patch.yml` 编辑(`default`、`includeUserRoot`、`roots`)在重启前都不生效。 - -## 决定 - -内置根是一个派生,不是一个 overlay。`resolveShippedPresetPatch(rows)` 从一份已组合的行集构建 roster 补丁:保留全部已配置的键,并把内置根(`system` 信任)前置到组合的 `roots` 中,因此内置 preset 始终挂载并在 id 冲突时胜出,而配置的根目录保持生效。`composeProfilePatches(layers)` 把该补丁追加到展平后的补丁栈,是启动与用户层热重载共用的构建器——热重载从当前用户层派生而非重放启动快照。配置 dump 共用的是派生本身而非构建器:`renderConfigDump` 需要逐层标注来源,所以 `runDumpConfig` 把 `resolveShippedPresetPatch` 的输出作为独立一层追加(标注为 `dsh launcher (shipped agent-preset root)`),对 roster 行的组合与实际启动完全一致。遥测开关仍是仅启动时的 overlay:它是启动进程的环境事实,不携带 config 快照,压过用户编辑正是其目的。 - -启动器无法静态改写的 `roots` 值——`!!js` 表达式或任何非数组——现在以指明约束的 `TypeError` 大声失败,而不是被静默替换。插件自身的契约不变:`config.roots` 按序扫描,可写 home 根由 `dsh-agent-presets` 自己追加。 - -## 测试 - -`shipped-preset-root.spec.ts` 直接覆盖派生逻辑:前置顺序、键保留、无 roster 行时不产出、逐次调用派生、大声失败的拒绝分支,以及经完整 `composeEntries` 应用验证的压掉回归。Web 组合 e2e 现在通过真实的 `composeProfilePatches` 获得内置根,不再手抄启动器补丁(此前三处启动逐字复制了它,其中一处自述"exactly what `composeProfile` supplies"),并新增配置根目录的启动场景:共享根的 preset 与内置四个并列出现、占用内置 id 的目录被其遮蔽、配置根中的 preset 能组合出 agent。built-bin dump 验收断言派生层标签及"内置根在配置根之前"的顺序。无 keyless 快照变更:默认组合产生的补丁栈逐字节相同,且快照框架没有自定义 profile 通道——真实组合 e2e 即是组装应用层面的证据。 - -## 曾考虑的替代方案 - -**报告者的修法:在启动时 overlay 内部做前置。** 对压掉问题与优先级顺序判断正确,派生补丁保留了这一形状。按原样采纳被否,因为该 overlay 仍会把启动时的整个 `config` 冻结在所有后续重载之上,该行的实时编辑在重启前依然失效。 - -**带外提供内置根(启动器提供的上下文值,由插件前置)。** 热重载故事最干净——完全不改写 config——但它把装配事实挪进插件的服务契约,给一个本只读 config 的包加上与启动器耦合的 provide 键,还让有效根目录对配置 dump 不可见。派生补丁把 roster 的输入完整留在组合之内。 - -## 后果 - -配置的 preset 根目录在启动后存活,对 roster 行的实时编辑无需重启即生效,dump、活动树与启动对该行的组合完全一致。启动器将 roster 行的 `config`/`roots` 约束为字面量;此前用 `!!js` 生成它们的组合本来就会被静默丢弃表达式,现在必须在某个补丁层实体化该数组。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml similarity index 52% rename from .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml index 26aa999982..a25b27d6de 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.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/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md -2026-08-20-derive-shipped-preset-root-per-composition.md: ecf852ade3720cbf5f5f99efa073cc6e3a352fec -2026-08-20-derive-shipped-preset-root-per-composition.zh.md: 718bddd6e4159b32db63262bb40a1e0ce38227ac +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md +2026-08-20-plugin-owned-shipped-preset-root.md: 43bcc685c2edfa5d125139b998d75ce8b308f60d +2026-08-20-plugin-owned-shipped-preset-root.zh.md: c2cc586a7a17d7cdb818523a74320fae106eb773 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md new file mode 100644 index 0000000000..43bcc685c2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md @@ -0,0 +1,33 @@ +# Agent Note: The shipped preset root is the plugin's own + +Status: implemented + +English | [中文](2026-08-20-plugin-owned-shipped-preset-root.zh.md) + +## Problem + +`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a shared preset directory booted with only the shipped root plus the roster's writable home root, and every custom preset vanished from the Web picker. `dsh --dump-config` composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them. The overlay also froze the row's boot-time `config` above every live reload, so no `cordis.patch.yml` edit to the row took effect until restart. Externally reported with an accurate root cause in discussion #3636. + +Under the whole-`config`-replacement patch semantics, any "must survive user layers" value needs enforcement after composition — and review rejected keeping that enforcement in the launcher: `apps/cli` special-casing one plugin's row id, config keys, and precedence is coupling the composition machinery should not carry. + +## Decision + +The shipped presets are the plugin's own. The four built-in compositions moved from `apps/cli/config/agent-presets/` into `packages/preset/agent-presets/presets/`, listed in the package's `files`, and `dsh-agent-presets` resolves `SHIPPED_PRESET_ROOT` relative to its own module — the Loader imports the plugin by package name at runtime, so the directory exists on disk in both the source and installed layouts, the same mechanism that lets the `cordis` preset carry its skills inside its directory. `resolvedRoots` becomes shipped root (`system` trust) unless `includeShippedRoot` is false, then `config.roots` in order, then the derived writable home root unless `includeUserRoot` is false — prepended, so the shipped set always mounts and wins a duplicate id. + +This completes the [per-session preset roster](../architecture/2026-08-03-per-session-agent-presets.md) direction that #2278 started for the writable root: both non-configured roots are now the package's, the launcher composes patch layers with no plugin knowledge, and the squash, the reload freeze, and the dump divergence stop being possible rather than being corrected. The always-load guarantee no longer rides patch ordering: `includeShippedRoot` defaults true in the schema, so a user layer replacing the row's whole `config` keeps the shipped set, and only an explicit `false` — as deliberate as disabling the row — drops it. The compositions bind to the host's agent-plane services, not to the Web surface: no preset row names a client or web plugin, and a host lacking an injected service leaves that row waiting exactly as under any other root. + +## Testing + +`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets healthy and `system`-trusted (proving the moved files resolve from the package), the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location. + +## Alternatives considered + +**Keep the launcher patch but derive it per composition, prepending instead of replacing.** The first merged-nowhere iteration of this fix: correct on the squash, the reload freeze, and the dump (which gained the derived layer as a labeled dump layer), with the reporter's overlay-prepend shape as its core. Superseded in review because every variant keeps `apps/cli` special-casing the roster row; the coupling, not the mechanics, was the objection. + +**Have the bundle declare the shipped root itself (`!!js` package-relative path).** Removes the launcher coupling but hangs the always-load guarantee back on patch ordering: a user layer replacing the row's `config` drops the bundle's entry — the reported bug's shape again. + +**Provide the root out of band (a launcher-provided context value the plugin prepends).** The launcher still has to know to provide a preset fact; the special case survives in a different channel. + +## Consequences + +`config.roots` is purely deployment-added directories; the dump shows exactly that, and the shipped root is documented plugin behavior surfaced at runtime through `agentPresets.roots`. `apps/cli` ships no `config/` directory and its `files` entry is gone. Any composition that mounts the roster — and any embedder of the package — gets the shipped set by default and turns it off with one config line; embedders wanting bare machinery set `includeShippedRoot: false`. The presets' bare plugin names still resolve through the boot's flat installation fallback, unchanged by the move. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md new file mode 100644 index 0000000000..c2cc586a7a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md @@ -0,0 +1,33 @@ +# Agent Note: The shipped preset root is the plugin's own + +Status: implemented + +[English](2026-08-20-plugin-owned-shipped-preset-root.md) | 中文 + +## 问题 + +`composeProfile` 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 `config` 展开已组合的 roster 行后,把 `roots` 硬设为仅含内置根。由于 id 定向补丁整体替换 `config` 值,这个 overlay 压掉了 profile 的 `cordis.patch.yml`(以及 home 层、`--patch` overlay)配置的全部根目录:把 `agent-presets` 指向共享 preset 目录的部署,启动后只剩内置根加 roster 的可写 home 根,所有自定义 preset 从 Web 选择器中消失。`dsh --dump-config` 只组合文件承载的层,dump 显示配置的根目录完好而启动却丢弃了它们。该 overlay 还把行的启动时 `config` 冻结在所有热重载之上,重启前对该行的任何 `cordis.patch.yml` 编辑都不生效。外部报告 discussion #3636 给出了准确根因。 + +在"补丁整体替换 `config`"的语义下,任何"必须在用户层之后存活"的值都需要组合后的强制注入——而评审否决了把这份强制留在启动器里:`apps/cli` 对某一个插件的行 id、config 键与优先级做特判,是组合机器不应携带的耦合。 + +## 决定 + +内置 preset 归插件自有。四套内置组合从 `apps/cli/config/agent-presets/` 搬入 `packages/preset/agent-presets/presets/`,列入包的 `files`;`dsh-agent-presets` 相对自己的模块解析 `SHIPPED_PRESET_ROOT`——Loader 在运行时按包名导入插件,目录在源码与安装两种布局中都真实存在于磁盘上,与 `cordis` preset 目录内随行携带 skill 依赖的是同一机制。`resolvedRoots` 变为:除非 `includeShippedRoot` 为 false,先是内置根(`system` 信任),再按序 `config.roots`,最后除非 `includeUserRoot` 为 false 追加推导的可写 home 根——前置,因此内置集合始终挂载并赢得重复 id。 + +这补全了 #2278 为可写根开启的[会话级 preset roster](../architecture/2026-08-03-per-session-agent-presets.zh.md) 方向:两个非配置根现在都属于本包,启动器不带任何插件知识地组合补丁层,压掉、重载冻结与 dump 分叉从"被修复"变为"不再可能发生"。"一定加载"的保证不再依赖补丁顺序:`includeShippedRoot` 在 schema 中默认 true,用户层整体替换该行 `config` 后内置集合依然保留,只有显式 `false`——与整行 disable 同级的故意行为——才会去掉它。组合绑定的是宿主的 agent-plane 服务而非 Web 表面:没有任何 preset 行引用 client 或 web 插件;宿主缺少被注入的服务时,该行保持等待,与任何其他根目录下的 preset 无异。 + +## 测试 + +`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 且健康、`system` 信任(证明搬移后的文件能从包内解析);内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。 + +## 曾考虑的替代方案 + +**保留启动器补丁但按组合派生、前置而非替换。** 本修复未曾合入的第一版:对压掉、重载冻结与 dump(曾以带标签层渲染派生补丁)判断均正确,核心即报告者的 overlay 前置形状。在评审中被替代,因为每个变体都让 `apps/cli` 对 roster 行做特判;被否决的是耦合而非机制。 + +**由 bundle 自己声明内置根(`!!js` 包相对路径)。** 去掉启动器耦合,但把"一定加载"的保证重新挂回补丁顺序:用户层整体替换该行 `config` 时 bundle 的条目被丢弃——又回到所报 bug 的形状。 + +**带外提供根目录(启动器提供的上下文值,由插件前置)。** 启动器仍需知道"要为 preset 提供一个事实";特判换了通道继续存在。 + +## 后果 + +`config.roots` 纯粹是部署追加的目录;dump 展示的正是它,内置根成为文档化的插件行为,运行时经 `agentPresets.roots` 呈现。`apps/cli` 不再携带 `config/` 目录,其 `files` 条目移除。任何挂载 roster 的组合——以及任何嵌入本包的使用方——默认获得内置集合,一行配置即可关闭;只要纯机制的嵌入方设 `includeShippedRoot: false`。preset 里的裸插件名仍经启动的扁平安装后备解析,搬移不改变这一点。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index ab658f689f..df7b3d700b 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.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-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 982b0b87553e15fab8fd6a2718dbe2462cfb3bd9 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 05935f8164018d0c476f85ccc5e6c0e87890f979 +2026-07-29-persistent-bash-str-replace-editor.md: e8e37b7e534773429a9c6fe0f63bb8d5460de364 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 71034ba615e09e09ec03212b6d5535959df73f4a diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 982b0b8755..e8e37b7e53 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. +The shipped [`minimal` agent preset](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 05935f8164..71034ba615 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)负责说明。 +随附的 [`minimal` agent preset](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)负责说明。 ## 考虑过的替代方案 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index c33c75f2cb..fbea2bc740 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: ae6f4c38eee402bcc1a86ba34778afd06da749b1 -README.zh.md: d4661476e4310b987b22fec7a7d417b9ea6811ab +README.md: 9a8d722b044ed5d8e31e3c27e54f8c9ef0839f82 +README.zh.md: c092414e2d15e90133d8ea27f0af5cadbe527b22 diff --git a/apps/cli/README.md b/apps/cli/README.md index ae6f4c38ee..9a8d722b04 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -35,7 +35,6 @@ The tree composes over an empty root: - each bundle's patch in `dsh.profile.bundles` order - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml` - then `--patch` overlays -- then, when the composition mounts the preset roster, a launcher-derived patch that prepends the shipped agent-preset root to the configured `roots` Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index d4661476e4..c092414e2d 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -37,7 +37,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 - `dsh.profile.bundles` 中各组合包的 patch - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml` - `--patch` 指定的覆盖层 -- 组合挂载预设 roster 时,启动器再派生一个补丁,把内置 agent-preset 根目录前置到已配置的 `roots` 之前 `dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 30e6f5da57..209411aaf2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,8 +15,7 @@ "dsh": "lib/bin.js" }, "files": [ - "lib/*.js", - "config" + "lib/*.js" ], "license": "MIT", "dependencies": { diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0d5bb37319..117c2c6aac 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 2e8c262f5c1e7045472eaaa53657e9b9a7bab9a4 -README.zh.md: 27a63337b6166d3aa560a2205912575a007b0c72 +README.md: dfddd177a78c348793d3e5c2d290fa62c5ac850b +README.zh.md: 8e7508b4b8fcbd39e15538c6ee88733bfa9905f1 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 2e8c262f5c..dfddd177a7 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. When the composition mounts the preset roster, both also append the launcher-derived `dsh launcher (shipped agent-preset root)` layer, so the dump composes that row exactly as boot does. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 27a63337b6..8e7508b4b8 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。当组合挂载预设 roster 时,两者还会追加启动器派生的 `dsh launcher (shipped agent-preset root)` 层,因此 dump 对该行的组合与实际启动完全一致。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 ## 插件管理 diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 229a4c67ab..1754eb4efd 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -2,8 +2,7 @@ * Config-dump entry for `dsh --profile --dump-config`: compose the * profile's patch layers through the include plugin's patch algorithm without * booting or evaluating `!!js`, with one source layer per bundle, the - * profile's own patch file, each `--patch` overlay, and the launcher-derived - * shipped agent-preset root. + * profile's own patch file, and each `--patch` overlay. * @module @deepseek-ai/dsh/dump-config */ @@ -15,7 +14,7 @@ import { renderConfigDump, type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' -import { composeRows, homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME, resolveShippedPresetPatch } from './profile-boot.ts' +import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' const NAME = 'dsh' @@ -48,15 +47,6 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) }) } } - // The launcher derives one more layer no file carries: the shipped - // agent-preset root, prepended to whatever roots the layers configured. - // Included so the dump composes the roster row exactly as it boots. (The - // telemetry hard-disable switch stays out: it is an environment fact of the - // booting process, not part of the profile composition.) - const presetPatch = resolveShippedPresetPatch(composeRows(layers.map(layer => layer.patches))) - if (presetPatch !== undefined) { - layers.push({ label: `${NAME} launcher (shipped agent-preset root)`, patches: [presetPatch] }) - } // The dump anchors on the same empty root file the boot includes. process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers)) } diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 68ce0d9749..ac5d7e83ec 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,10 +1,9 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's - * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch, and the - * per-composition derived shipped agent-preset root), mount the tree over the - * profile's empty root config, keep the profile patch layer live, and wire - * fail-loud plus bounded shutdown. + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the + * tree over the profile's empty root config, keep the profile patch layer + * live, and wire fail-loud plus bounded shutdown. * * App flags are not the launcher's business: the invocation's inner arguments * are provided to the tree through `ctx.cmdlineArgs`, where any injected app @@ -17,7 +16,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -31,10 +30,6 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' - -/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ -const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) - import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' @@ -116,74 +111,12 @@ interface ComposedProfile { /** The full patch stack of one composed profile, in application order. */ function allPatches(composed: ComposedProfile): PatchOptions[] { - return composeProfilePatches([ - composed.bundlePatches, - composed.profile.patches, - composed.homePatches, - composed.overlays, - ]) -} - -/** - * Compose patch layers and index the resulting rows by id. - * @param layers - patch lists in application order. - * @returns id → composed row, for rows that carry a string id. - */ -export function composeRows(layers: readonly PatchOptions[][]): Map { - const rows = new Map() - for (const row of composeEntries(layers)) { - if (typeof row.id === 'string') rows.set(row.id, row) - } - return rows -} - -/** - * Derive the shipped agent-preset-root patch from one composed row set. The - * shipped root is the part of the roster only this app can resolve: it sits - * beside this app's own config, in both the source and built layouts. The - * derived patch keeps every configured key and PREPENDS the shipped root to - * the composition's `roots`, so the shipped presets always mount and win a - * duplicate id while configured roots stay live. (The writable root the - * roster appends is `dsh-agent-presets`' own, so a launcher that never - * reaches this patch still finds a person's presets.) - * @param rows - id → row of the composed tree the patch applies over. - * @returns the roster patch, or `undefined` when the composition has no roster row. - * @throws TypeError when the composed row's config or its `roots` is not a - * literal the launcher can rewrite (a `!!js` expression or a non-array value). - */ -export function resolveShippedPresetPatch(rows: ReadonlyMap): PatchOptions | undefined { - const row = rows.get('agent-presets') - if (row === undefined) return undefined - const config: unknown = row.config ?? {} - if (typeof config !== 'object' || config === null || Array.isArray(config) || isJsExpr(config)) { - throw new TypeError(`${NAME}: agent-presets config must be a literal mapping — the launcher prepends the shipped preset root into it`) - } - const configured = (config as Record).roots ?? [] - if (!Array.isArray(configured)) { - throw new TypeError(`${NAME}: agent-presets config.roots must be a literal array — the launcher prepends the shipped preset root into it`) - } - const configuredRoots: readonly unknown[] = configured - return { - id: 'agent-presets', - config: { - ...(config as Record), - roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }, ...configuredRoots], - }, - } -} - -/** - * Compose one generation's full patch stack: the layers in application order, - * then the shipped preset-root patch derived from their composition. Shared - * by boot and the live user-layer reloads, so a reload derives the roster - * from the CURRENT user layers instead of replaying a boot-time snapshot — - * an edit to the row's config, `roots` included, keeps taking effect. - * @param layers - patch lists in application order. - * @returns the flattened stack with the derived roster patch appended. - */ -export function composeProfilePatches(layers: readonly PatchOptions[][]): PatchOptions[] { - const presetPatch = resolveShippedPresetPatch(composeRows(layers)) - return [...layers.flat(), ...presetPatch === undefined ? [] : [presetPatch]] + return [ + ...composed.bundlePatches, + ...composed.profile.patches, + ...composed.homePatches, + ...composed.overlays, + ] } /** @@ -205,10 +138,10 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) - const rows = composeRows([bundlePatches, profile.patches, homePatches, overlays]) - // The shipped agent-preset root is NOT pushed here: it is derived from the - // current layers on every composition (`composeProfilePatches`), so a live - // user-layer edit to the roster row keeps taking effect. + const rows = new Map() + for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { + if (typeof row.id === 'string') rows.set(row.id, row) + } const composedOverlays = [...overlays] const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) @@ -282,14 +215,12 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // objects in place. Reusing one parsed patch object across applications // would bake a user override into the bundle's in-memory insert row, so // removing the override could never revert the row to the bundle default. - // The derived shipped-preset patch is recomputed per generation from these - // fresh layers, never carried over from boot. - const composeLive = (): PatchOptions[] => structuredClone(composeProfilePatches([ - composed.bundlePatches, - loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], - loadOptionalPatches(NAME, homePatchPath()) ?? [], - composed.overlays, - ])) + const composeLive = (): PatchOptions[] => structuredClone([ + ...composed.bundlePatches, + ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], + ...loadOptionalPatches(NAME, homePatchPath()) ?? [], + ...composed.overlays, + ]) // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index a2c4fa97c0..75ab640fcc 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -748,12 +748,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ' - id: personal', ' provider: personal-provider', ' model: personal-model', - '- id: agent-presets', - ' config:', - ' default: standard', - ' roots:', - ` - path: ${join(home, 'team-presets')}`, - ' trust: user', '- id: absent-row', ' config:', ' x: 1', @@ -778,13 +772,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toContain('personal-provider') // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) - // The dump composes the launcher-derived roster layer too: the shipped - // preset root is prepended to the user layer's roots, not replacing them. - expect(stdout).toContain('dsh launcher (shipped agent-preset root)') - const shippedRootAt = stdout.search(/config[\\/]+agent-presets/) - const configuredRootAt = stdout.search(/team-presets/) - expect(shippedRootAt).toBeGreaterThanOrEqual(0) - expect(configuredRootAt).toBeGreaterThan(shippedRootAt) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) }) diff --git a/apps/cli/tests/shipped-preset-root.spec.ts b/apps/cli/tests/shipped-preset-root.spec.ts deleted file mode 100644 index 3f43daa96c..0000000000 --- a/apps/cli/tests/shipped-preset-root.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { sep } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { composeEntries } from '@deepseek-ai/dsh-app-boot' -import { composeProfilePatches, composeRows, resolveShippedPresetPatch } from '../src/profile-boot.ts' - -/** The web bundle's roster insert, reduced to the keys the derivation reads. */ -const bundleLayer: PatchOptions[] = [{ - insert: [{ id: 'agent-presets', name: '@deepseek-ai/dsh-agent-presets', config: { default: 'standard' } }], -}] - -const userLayer = (config: Record): PatchOptions[] => [{ id: 'agent-presets', config }] - -const shippedRoot = { path: expect.stringContaining(`config${sep}agent-presets`) as unknown, trust: 'system' } - -/** Apply a full patch stack the way boot does and return the roster row's mounted config. */ -function finalRosterConfig(patches: PatchOptions[]): Record { - const row = composeEntries([patches]).find(entry => entry.id === 'agent-presets') - if (row === undefined) throw new Error('missing agent-presets row') - return row.config as Record -} - -describe('resolveShippedPresetPatch', () => { - it('is absent for a composition without the roster row', () => { - const rows = composeRows([[{ insert: [{ id: 'other', name: '@deepseek-ai/dsh-other' }] }]]) - expect(resolveShippedPresetPatch(rows)).toBeUndefined() - }) - - it('prepends the shipped root to configured roots and preserves every other key', () => { - const rows = composeRows([bundleLayer, userLayer({ - default: 'minimal', - roots: [{ path: `${sep}shared${sep}presets`, trust: 'user' }], - includeUserRoot: false, - })]) - expect(resolveShippedPresetPatch(rows)).toEqual({ - id: 'agent-presets', - config: { - default: 'minimal', - includeUserRoot: false, - roots: [shippedRoot, { path: `${sep}shared${sep}presets`, trust: 'user' }], - }, - }) - }) - - it('supplies the shipped root alone when the composition configures none', () => { - const patch = resolveShippedPresetPatch(composeRows([bundleLayer])) - expect(patch).toEqual({ id: 'agent-presets', config: { default: 'standard', roots: [shippedRoot] } }) - }) - - it('fails loud on a config it cannot statically rewrite', () => { - expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, userLayer({ default: 'standard', roots: 'nope' })]))) - .toThrow(TypeError) - expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, userLayer({ default: 'standard', roots: { __jsExpr: 'x' } })]))) - .toThrow(/literal array/) - expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, [{ id: 'agent-presets', config: { __jsExpr: 'x' } }]]))) - .toThrow(/literal mapping/) - }) -}) - -describe('composeProfilePatches', () => { - it('keeps configured roots effective through the whole patch application', () => { - // The squash this stack exists to prevent: the derived patch must extend - // the user layer's roots, not replace them with the shipped root. - const config = finalRosterConfig(composeProfilePatches([bundleLayer, userLayer({ - default: 'standard', - roots: [{ path: `${sep}shared${sep}presets`, trust: 'user' }], - includeUserRoot: true, - })])) - expect(config.roots).toEqual([shippedRoot, { path: `${sep}shared${sep}presets`, trust: 'user' }]) - expect(config.default).toBe('standard') - expect(config.includeUserRoot).toBe(true) - }) - - it('derives from the layers each call is given, not from an earlier composition', () => { - // The live user-layer reload calls this per generation: an edited - // cordis.patch.yml must decide the derived roots, never a boot snapshot. - composeProfilePatches([bundleLayer, userLayer({ default: 'standard', roots: [{ path: `${sep}one`, trust: 'user' }] })]) - const config = finalRosterConfig(composeProfilePatches([bundleLayer, userLayer({ - default: 'standard', - roots: [{ path: `${sep}two`, trust: 'user' }], - })])) - expect(config.roots).toEqual([shippedRoot, { path: `${sep}two`, trust: 'user' }]) - }) - - it('appends nothing to a composition without the roster row', () => { - const layers = [[{ insert: [{ id: 'other', name: '@deepseek-ai/dsh-other' }] }]] - expect(composeProfilePatches(layers)).toEqual(layers.flat()) - }) -}) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index f448586279..879564299a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -11,8 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' -import { composeProfilePatches } from '../src/profile-boot.ts' +import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' @@ -22,7 +21,6 @@ import type {} from '@deepseek-ai/dsh-tools' 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)) /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') @@ -99,8 +97,8 @@ async function bootWeb( // Pin the roster away from the developer's machine: `includeUserRoot` // false keeps `~/.dsh/.agent-presets` from changing a test's outcome. // `default` here is the COMPOSITION default — the base layer the settings - // document overrides. No `roots` entry: the launcher's real derivation - // below prepends the shipped root, exactly as `runProfile` composes it. + // document overrides. No `roots` entry: the plugin bundles the shipped + // presets itself and prepends their root. { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, ...extra, ] @@ -137,10 +135,7 @@ async function bootWeb( } const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - // The shipped preset root arrives the way the real launcher delivers it: - // derived over these same layers, appended after every override. - const patches = composeProfilePatches([bundlePatches, overrides]) - return await boot('dsh-test', rootConfig, patches, (bootCtx) => { + return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => { provideCmdline(bootCtx, { args: [], exit: () => {} }) }) } @@ -363,7 +358,7 @@ describe('the shipped Web composition', () => { // The preset's skill root is derived from its own `baseUrl`, so the skill // travels with the directory wherever the preset is installed. const skill = join( - CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + SHIPPED_PRESET_ROOT, 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', ) expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) @@ -435,7 +430,7 @@ describe('the shipped Web composition', () => { // agent down disposes its whole subtree. Inherited, that rewrote the // shipped composition — truncating it to `[]` the first time a session // ended — so `PresetTree` refuses to write at all. - const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml') + const path = join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml') const before = await readFile(path, 'utf8') const handle = await ctx.agents.create({ @@ -463,7 +458,7 @@ describe('product Bundle and user-preset intersection', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) const userRoot = join(root, 'presets') const settingsFile = join(root, 'settings.yaml') - const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') + const standard = await readFile(join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml'), 'utf8') await writeFile(settingsFile, '{}\n') for (const id of presetIds) { let composition = standard @@ -490,7 +485,7 @@ describe('product Bundle and user-preset intersection', () => { id: 'agent-presets', config: { default: 'standard', - // The shipped root is bootWeb's derivation, prepended before this. + // The shipped root is the plugin's own, prepended before this. roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, @@ -726,7 +721,7 @@ describe('a launcher that configures no writable root', () => { ) const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') await writeFile(settingsFile, '{}\n') - // No configured roots: the shipped one is bootWeb's derivation, and the + // No configured roots: the shipped one is the plugin's own, and the // writable one is the roster's own default rather than this patch's job. derivedCtx = await bootWeb(settingsFile, [{ id: 'agent-presets', @@ -774,8 +769,8 @@ describe('authoring a preset on the shipped composition', () => { config: { default: 'standard', // The root does not exist yet: a deployment whose user has authored - // nothing is the normal first-run state. The shipped root is bootWeb's - // derivation, prepended before this. + // nothing is the normal first-run state. The shipped root is the + // plugin's own, prepended before this. roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, @@ -895,14 +890,14 @@ describe('a composition that configures its own preset roots', () => { // A workspace-shared root beside the deployment: one preset of its own, // plus a directory that claims a shipped id. teamRoot = join(home, 'team-presets') - const minimalComposition = await readFile(join(CONFIG_DIR, 'agent-presets', 'minimal', 'agent.cordis.yml'), 'utf8') + const minimalComposition = await readFile(join(SHIPPED_PRESET_ROOT, 'minimal', 'agent.cordis.yml'), 'utf8') for (const id of ['team-spec', 'minimal']) { await mkdir(join(teamRoot, id), { recursive: true }) await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition) } // The user layer of the reported regression: a profile's cordis.patch.yml - // configuring a shared preset root. The derivation must EXTEND it with - // the shipped root, never replace it. + // configuring a shared preset root. The plugin must EXTEND it with its + // own shipped root, never lose it. rootsCtx = await bootWeb(settingsFile, [{ id: 'agent-presets', config: { @@ -919,7 +914,7 @@ describe('a composition that configures its own preset roots', () => { it('keeps configured roots alongside the always-prepended shipped root', async () => { expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([ - expect.stringContaining(join('config', 'agent-presets')), + SHIPPED_PRESET_ROOT, teamRoot, ]) diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index ce37022cdf..f94ce582a6 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -13,11 +13,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { mkdtempSync, rmSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { evaluate } from '@deepseek-ai/cordis-plugin-loader' +import { SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' /** @@ -101,7 +102,7 @@ describe('the shipped shell composition (real bundle layers)', () => { }) describe('shipped agent presets gate both shell tools by platform', () => { - const presetRoot = resolve(fileURLToPath(new URL('../package.json', import.meta.url)), '..', 'config', 'agent-presets') + const presetRoot = SHIPPED_PRESET_ROOT it.each(['standard', 'code', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { const entries: unknown = yaml.load( diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 1a27f96c6e..f16f81edfb 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -27,8 +27,8 @@ const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md') -/** The shipped roster, beside the composition that names it. */ -const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +/** The shipped roster, bundled inside the `dsh-agent-presets` package. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../../packages/preset/agent-presets/presets', import.meta.url)) const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) const MODE = webSnapshotMode() @@ -60,10 +60,8 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, agentPresets: { - roots: [ - { path: SHIPPED_PRESETS, trust: 'system' }, - { path: userRoot, trust: 'user' }, - ], + // The shipped root is the plugin's own, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], default: 'standard', }, }) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 3d7c10abbc..7153b7ba30 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -1,7 +1,5 @@ -// Web e2e scenario: agent-preset selection. The roster's `roots` is an -// assembly fact the CLI entry resolves and patches in, so every other lane -// boots with an empty roster and no preset surface at all; this is the one -// lane that mounts the SHIPPED presets and puts them in front of a browser. +// Web e2e scenario: agent-preset selection. Every lane mounts the plugin's +// own shipped presets; this is the lane that puts them in front of a browser. // // Two surfaces, one host rule: a session's composition is fixed when the // session starts. Before that, the new-session chip stages the choice beside @@ -30,8 +28,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') -/** The shipped roster, beside the composition that names it. */ -const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'agent-preset-selection-web-e2e' /** A project skill only a preset that mounts `skill-filesystem` can discover. */ @@ -172,9 +168,9 @@ describe('web e2e: agent-preset selection', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ - agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' }, - }) + // The scaffold's default roster pin is exactly this scenario's shape: the + // plugin's shipped presets, default `standard`. + scaffold = await launchWebScaffold({}) // A resumed session runs what it was created with; seeding one that // records `minimal` is what makes the header label a claim about the // session rather than an echo of the current default. diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6ecef055ce..5f0aa6f518 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -101,8 +101,6 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the profile module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') -/** The deployment's own agent-preset root, shipped beside the app's config. */ -const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -268,15 +266,14 @@ export interface LaunchOptions { apiKeyEnv: string } /** - * Replace the roster the scaffold mounts by default (the shipped directory - * at `system` trust, default `standard`). Supply this only to change WHICH - * presets a scenario sees — a writable user root, a different default — - * never to turn the roster on: without one every session composes an agent - * with no tools, no persona, and no token meter, which is not a shape the - * product ever boots in. The patch lands after the default, so it wins. + * Replace the roster row the scaffold pins by default (no configured roots, + * default `standard` — the plugin's own shipped presets). Supply this only + * to change WHICH presets a scenario sees beyond the shipped set — a + * writable user root, a different default. The patch lands after the + * default, so it wins. */ agentPresets?: { - /** Roots to discover, in precedence order; the shipped directory is `system`. */ + /** Roots to discover after the plugin's shipped root, in precedence order. */ roots: { path: string; trust: 'system' | 'user' }[] /** The preset a session that names none is composed from. */ default: string @@ -401,20 +398,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/.agent-presets` as a `user` root, after every configured root | An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. -### The writable root is this package's, the shipped root is the app's +### The shipped and writable roots are this package's + +The shipped presets travel inside this package, beside `lib/`, the way each preset's own skills travel inside its directory. Their root is PREPENDED before every configured root, so the built-in set always mounts and wins a duplicate id — no patch layer replacing the roster row's `config` can accidentally drop it, and the schema default keeps the set through a whole-`config` replacement. The compositions require the host's agent-plane services, not any one surface: a host lacking a service a preset row injects leaves that row waiting, exactly as under any other root. `/.agent-presets` is where a person's own presets live, the way `/skills` is where their own skills live ([`dsh-skill-filesystem`](../../skill/skill-filesystem/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve. The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. -`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `` decides what the roster contains. +`includeShippedRoot: false` drops the built-in set — for a deployment supplying purely its own presets, or an embedder using the roster as bare machinery. `includeUserRoot: false` drops the derived writable root — for a deployment that confines presets to its own directories. A test pinning an exact roster sets both off; otherwise the package's shipped presets and the machine's real `` decide what the roster contains. The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index a786afbc37..f11fa5f092 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -87,17 +87,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen |---|---|---| | `default` | 必填 | 调用方未指定时挂载的 preset id | | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | +| `includeShippedRoot` | `true` | 在全部已配置根目录之前,前置本包随附的内置 preset 作为 `system` 根目录 | | `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `/.agent-presets` 作为 `user` 根目录 | 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 -### 可写根目录属于本包,随附根目录属于 app +### 随附根目录与可写根目录都属于本包 + +随附的 preset 就在本包内部、`lib/` 旁随行分发,正如每个 preset 自己的 skill 随其目录一起走。其根目录前置在全部已配置根目录**之前**,因此内置集合始终挂载并赢得重复 id——任何整体替换 roster 行 `config` 的补丁层都不会意外弄丢它,schema 默认值让该集合在整份 `config` 被替换后依然保留。这些组合依赖的是宿主的 agent-plane 服务,而不是某个特定表面:宿主缺少某个 preset 行注入的服务时,该行保持等待,与任何其他根目录下的 preset 无异。 `/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-filesystem`](../../skill/skill-filesystem/README.zh.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 -`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `` 决定 roster 的内容。 +`includeShippedRoot: false` 去掉内置集合——适用于只提供自有 preset 的部署,或把 roster 当作纯机制使用的嵌入方。`includeUserRoot: false` 去掉推导出的可写根目录——适用于把 preset 限制在自有目录内的部署。钉住确切 roster 的测试两者都要关——否则将由本包的随附 preset 与这台机器真实的 `` 决定 roster 的内容。 随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 08c035c953..4bd33091c2 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -33,7 +33,8 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "presets" ], "license": "MIT", "peerDependencies": { diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/packages/preset/agent-presets/presets/code/agent.cordis.yml similarity index 100% rename from apps/cli/config/agent-presets/code/agent.cordis.yml rename to packages/preset/agent-presets/presets/code/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/code/preset.yml b/packages/preset/agent-presets/presets/code/preset.yml similarity index 100% rename from apps/cli/config/agent-presets/code/preset.yml rename to packages/preset/agent-presets/presets/code/preset.yml diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml similarity index 100% rename from apps/cli/config/agent-presets/cordis/agent.cordis.yml rename to packages/preset/agent-presets/presets/cordis/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/packages/preset/agent-presets/presets/cordis/preset.yml similarity index 100% rename from apps/cli/config/agent-presets/cordis/preset.yml rename to packages/preset/agent-presets/presets/cordis/preset.yml diff --git a/apps/cli/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md similarity index 100% rename from apps/cli/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md rename to packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md similarity index 100% rename from apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md rename to packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/packages/preset/agent-presets/presets/minimal/agent.cordis.yml similarity index 100% rename from apps/cli/config/agent-presets/minimal/agent.cordis.yml rename to packages/preset/agent-presets/presets/minimal/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/packages/preset/agent-presets/presets/minimal/preset.yml similarity index 100% rename from apps/cli/config/agent-presets/minimal/preset.yml rename to packages/preset/agent-presets/presets/minimal/preset.yml diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/packages/preset/agent-presets/presets/standard/agent.cordis.yml similarity index 100% rename from apps/cli/config/agent-presets/standard/agent.cordis.yml rename to packages/preset/agent-presets/presets/standard/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/standard/preset.yml b/packages/preset/agent-presets/presets/standard/preset.yml similarity index 100% rename from apps/cli/config/agent-presets/standard/preset.yml rename to packages/preset/agent-presets/presets/standard/preset.yml diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 8e3ed2020b..5f3de734ac 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -16,6 +16,7 @@ import { readdir, readFile, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { load } from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { expandHomePath } from '@deepseek-ai/dsh-home-paths' @@ -29,10 +30,9 @@ export const COMPOSITION_FILE = 'agent.cordis.yml' * Harness-home directory holding locally authored presets. * * This package owns the writable root the way `dsh-skill-filesystem` owns - * `/skills`. An app must assemble the SHIPPED root, whose path only - * the installed app can resolve; where a person's own presets go is the same - * place in every deployment that does not say otherwise, so a launcher that - * forgets to configure one still finds them. + * `/skills`: where a person's own presets go is the same place in + * every deployment that does not say otherwise, so a launcher that forgets to + * configure one still finds them. * * Package-internal on purpose: no consumer outside this package addresses the * directory by name, and a test that imported it could not catch this value @@ -40,6 +40,15 @@ export const COMPOSITION_FILE = 'agent.cordis.yml' */ export const USER_PRESET_DIR = '.agent-presets' +/** + * The shipped presets, bundled inside this package: the roster's built-in + * compositions travel with the machinery that mounts them, the way each + * preset's own skills travel inside its directory. Resolved relative to this + * module so both launch layouts work — `src/` under tsx and the bundled + * `lib/` sit one level below the package root. + */ +export const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../presets/', import.meta.url)) + /** * Why `rows` cannot be an entry list, or undefined when it can. * diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 6a24a89d76..332c41a670 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -29,7 +29,7 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' -import { discoverPresets, USER_PRESET_DIR } from './discovery.ts' +import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' @@ -50,7 +50,7 @@ export const AgentPresetSettingsSchema: z = z.object({ default: z.string(), }) -export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' +export { COMPOSITION_FILE, discoverPresets, scanRoot, SHIPPED_PRESET_ROOT } from './discovery.ts' export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' @@ -89,18 +89,21 @@ export class AgentPresets extends Service { path: z.string().required(), trust: z.union(['system', 'user'] as const).default('user'), })).default([]), + includeShippedRoot: z.boolean().default(true), includeUserRoot: z.boolean().default(true), }) as z /** - * The roots discovery and authoring actually scan: every configured root in + * The roots discovery and authoring actually scan: the package's shipped + * root unless `includeShippedRoot` is false, then every configured root in * order, then the harness-home user root unless `includeUserRoot` is false. * * Derived once, because a root set that changed between `list()` and the * `copy()` acting on its answer would author into a directory the caller - * never saw. Appending rather than prepending keeps an earlier configured - * root winning a duplicate id, so a shipped preset still shadows a - * locally authored directory that claimed its name. + * never saw. The shipped root comes FIRST and the user root LAST because an + * earlier root wins a duplicate id: a shipped preset shadows any directory + * that claimed its name, and a configured root still shadows a locally + * authored one. */ private readonly resolvedRoots: readonly PresetRoot[] @@ -130,9 +133,11 @@ export class AgentPresets extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.selfCtx = ctx - this.resolvedRoots = config.includeUserRoot - ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }] - : [...config.roots] + this.resolvedRoots = [ + ...config.includeShippedRoot ? [{ path: SHIPPED_PRESET_ROOT, trust: 'system' } satisfies PresetRoot] : [], + ...config.roots, + ...config.includeUserRoot ? [{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' } satisfies PresetRoot] : [], + ] // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing @@ -338,10 +343,11 @@ export class AgentPresets extends Service { } /** - * The roots this roster scans, which is not `config.roots`: it is every - * configured root in order, then the harness-home user root unless - * `includeUserRoot` is false. Read this — not the config field — to answer - * whether a roster is composed at all, so one derivation decides it. + * The roots this roster scans, which is not `config.roots`: the package's + * shipped root unless `includeShippedRoot` is false, every configured root + * in order, then the harness-home user root unless `includeUserRoot` is + * false. Read this — not the config field — to answer whether a roster is + * composed at all, so one derivation decides it. */ get roots(): readonly PresetRoot[] { return this.resolvedRoots diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index 554348cdd6..bbb02c5623 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -54,9 +54,17 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Prepend this package's bundled shipped presets as a `system` root, before + * every configured root, so the shipped set always mounts and wins a + * duplicate id. The default survives a whole-`config` patch replacement; + * only an explicit `false` — a deployment supplying purely its own presets, + * or an embedder using the roster as bare machinery — drops the set. + */ + includeShippedRoot: boolean /** * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every - * configured root. False mounts a roster over `roots` alone. + * configured root. False mounts a roster without the derived writable root. */ includeUserRoot: boolean } diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index 8086996111..2166229cf6 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -52,9 +52,11 @@ beforeEach(async () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: userRoot, trust: 'user' as const }, ], - // Every roster in this file pins its own roots: the derived harness-home - // root would add the developer's real presets to what these assertions - // count, and `copy` would write into it. + // Every roster in this file pins its own roots: the package's shipped + // presets would shadow the fixture ids, and the derived harness-home root + // would add the developer's real presets to what these assertions count — + // and `copy` would write into it. + includeShippedRoot: false, includeUserRoot: false, }) }) @@ -203,6 +205,7 @@ describe('a deployment with more than one user root', () => { { path: userRoot, trust: 'user' as const }, { path: second, trust: 'user' as const }, ], + includeShippedRoot: false, includeUserRoot: false, }) @@ -224,6 +227,7 @@ describe('a deployment with no writable root', () => { await readOnly.plugin(AgentPresets, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + includeShippedRoot: false, includeUserRoot: false, }) @@ -246,6 +250,7 @@ describe('a user root that does not exist yet', () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: absent, trust: 'user' as const }, ], + includeShippedRoot: false, includeUserRoot: false, }) diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index dda3644f55..02352afb8e 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -31,7 +31,7 @@ async function harness(roster: Partial = {}): Promise { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantRegistry) await ctx.plugin(AgentPresetsInvariant) return ctx diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 824308e009..ed93a7de53 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -38,7 +38,7 @@ const ROOTS = [ * @param roster - roster config, defaulting to the fixture roots. * @returns the booted context. */ -async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise { +async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => { join(presetDir, COMPOSITION_FILE), `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, ) - const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false }) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeShippedRoot: false, includeUserRoot: false }) const imported = vi.spyOn(scoped.loader.internal!, 'import') await agentOn(scoped, 'sess-absolute-plugin') @@ -347,7 +347,7 @@ describe('composing from a broken preset', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) - return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) } it('refuses the mount up front with the discovery-reported reason', async () => { @@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) - await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false }) + await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) await expect(bare.agentPresets.resolve()) .rejects.toThrow(/preset "standard" not found \(available: none\)/) @@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) await scoped.agents.create({ sessionId: SessionId('sess-self-dispose'), @@ -534,7 +534,7 @@ describe('replacing a composition', () => { // exactly right there and the diagnostic must stay silent. Opting out is // what makes this rosterless — empty `roots` alone would still derive the // harness-home root, which is a roster like any other. - const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false }) + const rosterless = await harness({ default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) const warnings: string[] = [] rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn @@ -583,7 +583,7 @@ describe('replacing a composition', () => { await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) const handle = await scoped.agents.create({ sessionId: SessionId('sess-restore-gone'), setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), @@ -623,7 +623,7 @@ describe('editing a composition file', () => { await mkdir(join(root, id)) const path = join(root, id, COMPOSITION_FILE) await writeFile(path, rowFor('before')) - const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) return { scoped, path } } diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index e3af9e1ec1..1c549f7874 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -49,7 +49,7 @@ async function harness( await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(FileSettingsProvider, { path: settingsFile, watch: false }) await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeShippedRoot: false, includeUserRoot: false }) return { ctx, settingsFile, settingsFiber } } diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts new file mode 100644 index 0000000000..30b974aae8 --- /dev/null +++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts @@ -0,0 +1,90 @@ +/** + * The shipped presets are this package's own, not an assembly fact each app + * must patch in: a roster configured with nothing still supplies the built-in + * compositions, prepended so they always mount and win a duplicate id. + * `includeShippedRoot: false` is how a deployment supplying purely its own + * presets — or an embedder using the roster as bare machinery — opts out. + * + * `$DSH_HOME` is repointed per test for the same reason as the user-root + * suite: the derived writable root is resolved in the constructor. + */ + +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = join(FIXTURES, 'system') + +let previousHome: string | undefined + +beforeEach(async () => { + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-')) +}) + +afterEach(() => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome +}) + +/** Boot a roster with the shipped root left to the plugin's default. */ +async function roster(config: Partial = {}): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [], + includeShippedRoot: true, + includeUserRoot: true, + ...config, + }) + return ctx +} + +describe('the shipped preset root', () => { + it('supplies the built-in presets from a bare roster, healthy and system-trusted', async () => { + const ctx = await roster({ includeUserRoot: false }) + + const listed = await ctx.agentPresets.list() + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) + expect(listed.every(preset => preset.trust === 'system')).toBe(true) + expect(listed.every(preset => preset.broken === undefined)).toBe(true) + }) + + it('prepends the shipped root before configured roots and the derived user root', async () => { + const ctx = await roster({ roots: [{ path: SYSTEM_ROOT, trust: 'user' }] }) + + expect(ctx.agentPresets.roots.map(root => root.path)).toEqual([ + SHIPPED_PRESET_ROOT, + SYSTEM_ROOT, + expect.stringContaining('.agent-presets'), + ]) + expect(ctx.agentPresets.roots[0]).toEqual({ path: SHIPPED_PRESET_ROOT, trust: 'system' }) + // Prepended, so a configured directory claiming a shipped id is shadowed: + // the fixture root also carries `minimal`, and the roster serves the + // shipped one. + const minimal = (await ctx.agentPresets.list()).find(preset => preset.id === 'minimal') + expect(minimal?.path.startsWith(SHIPPED_PRESET_ROOT)).toBe(true) + }) + + it('mounts a roster without the shipped set when includeShippedRoot is false', async () => { + const ctx = await roster({ + includeShippedRoot: false, + includeUserRoot: false, + roots: [{ path: SYSTEM_ROOT, trust: 'system' }], + }) + + expect(ctx.agentPresets.roots).toEqual([{ path: SYSTEM_ROOT, trust: 'system' }]) + const minimal = (await ctx.agentPresets.list()).find(preset => preset.id === 'minimal') + expect(minimal?.path.startsWith(SYSTEM_ROOT)).toBe(true) + }) +}) diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts index 4ecf42864b..c749db8a75 100644 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -50,6 +50,8 @@ async function roster(config: Partial = {}): Promise { await ctx.plugin(AgentPresets, { default: 'standard', roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], + // The package's shipped presets would shadow this file's fixture ids. + includeShippedRoot: false, includeUserRoot: true, ...config, }) diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index b4c5d5736e..706b81b128 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 195a8bb23e..49981d16c0 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -84,7 +84,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // Asserts the vendored-manifest table, which gains an upstream-name column. { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) }, // `cordis` is also an agent-preset id — the directory name under - // apps/cli/config/agent-presets/ — so in these files the bare name is + // packages/preset/agent-presets/presets/ — so in these files the bare name is // product data, not a package reference. Renaming it changed which preset // the creator flow stages and which id the roster reports. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, @@ -98,7 +98,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // The preset's own composition: its header comment and its system prompt name // the preset a model mounts, so the scoped name would send the model after an // id no roster reports. - { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, + { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, // The preset-roster loop names the `cordis` preset id, not a package. { file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] }, // GROUP_ORDER holds `packages//` directory names, not package names. @@ -159,8 +159,8 @@ const POSTCONDITIONS: readonly PostCondition[] = [ // The preset ids in this table are product data, not package names. { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, // The preset id the shipped composition documents to its own model. - { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, - { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, + { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, + { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', text: '\'cordis\', \'loader\', \'include\', \'timer\', \'hmr\', \'logger-console\',', count: 1 }, ] diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 3fa1babcb9..9a10b4f2db 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -147,7 +147,7 @@ function validatePresetPlaneSeparation(): string[] { } // The overlay's own inserts are host-plane too; its disables take them back out. const active = new Set([...hostRows, ...rowIds(overlayFile)].filter(id => !disabled.has(id))) - for (const file of globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root })) { + for (const file of globSync('packages/preset/agent-presets/presets/*/agent.cordis.yml', { cwd: root })) { for (const id of rowIds(file)) { if (!active.has(id)) continue problems.push( diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 09a1b044ab..6a395afe30 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -39,7 +39,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` - id: tools name: cordis:group group: true @@ -68,7 +68,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` - id: conditional name: '@scope/conditional' disabled: !!js process.env.DSH_DISABLE_CONDITIONAL === '1' @@ -86,7 +86,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` - id: plugin name: '@scope/plugin' config: @@ -103,7 +103,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': '1.2.3' } }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` - id: plugin name: '@scope/plugin' `, @@ -126,7 +126,7 @@ describe('verifyRuntimeClosure', () => { expect(result.presetCount).toBe(0) expect(result.failures).toEqual([ - 'no agent presets matched apps/cli/config/agent-presets/*/agent.cordis.yml', + 'no agent presets matched packages/preset/agent-presets/presets/*/agent.cordis.yml', ]) }) @@ -134,7 +134,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, 'python/sdk-runtime/platforms.json': {}, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': '[]\n', + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': '[]\n', }) const result = await verifyRuntimeClosure(root) @@ -148,7 +148,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/root': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/minimal/agent.cordis.yml': '[]\n', + 'packages/preset/agent-presets/presets/minimal/agent.cordis.yml': '[]\n', }) workspace(root, '@scope/root', { peerDependencies: { '@scope/required': 'workspace:^', '@scope/optional': 'workspace:^' }, diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 927bae8db5..d0127fc68d 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -30,7 +30,7 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record -const AGENT_PRESET_GLOB = 'apps/cli/config/agent-presets/*/agent.cordis.yml' +const AGENT_PRESET_GLOB = 'packages/preset/agent-presets/presets/*/agent.cordis.yml' export interface RuntimeClosureResult { failures: string[] From d858832bbbd005e196d3821e67c2cd34a2c1a8dc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 21 Aug 2026 13:42:23 +0800 Subject: [PATCH 004/138] chore(constraints): register the preset-root files policy The files constraint tables gained per-package expectations on master while this branch changed two files lists: apps/cli no longer ships config/, and dsh-agent-presets ships presets/ (ordered where the expected-files derivation places extras). --- packages/preset/agent-presets/package.json | 4 ++-- scripts/check-workspace-constraints.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 4bd33091c2..8d42525d76 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -32,9 +32,9 @@ "files": [ "lib/index.js", "lib/invariant.js", + "presets", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "presets" + "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e87106ed14..c50ae50272 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -57,7 +57,7 @@ const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|app const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { - '@deepseek-ai/dsh': ['lib/*.js', 'config'], + '@deepseek-ai/dsh': ['lib/*.js'], // The Web build emits sourcemaps for browser debugging; publishing them is // what the payload policy forbids, so the bundle ships without them. '@deepseek-ai/dsh-web-frontend': ['dist', '!dist/**/*.map'], @@ -151,6 +151,8 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], // The CPython side ships as source .py files, published as-is rather than built. '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], + // The shipped preset compositions travel inside the roster package. + '@deepseek-ai/dsh-agent-presets': ['presets'], // The Python runtime uses a distinct closed-resolution bin; the public CLI // keeps config-owned bare-package resolution through lib/bin.js. '@deepseek-ai/dsh-sdk-jsonrpc-demo': ['lib/packaged-bin.js'], From c365daa53ca4a67069f59a14cdf5e1a9ba18bbd7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 21 Aug 2026 13:42:24 +0800 Subject: [PATCH 005/138] chore(rescope): realign two manifest anchors, allowlist the preset-id spec Exposed by this branch touching rescope-vendor.ts, which runs the full rescope check: the knip-logger-console exact edit targeted the packages/util/home knip section that #2758 deleted (drop the edit), the zh vendoring-cookbook anchor predates the rescope.zh.md link localization (follow it), and the new shipped-root.spec.ts joins the files whose bare 'cordis' tokens are preset ids. --- scripts/rescope-vendor.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 49981d16c0..040e27a308 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -88,6 +88,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // product data, not a package reference. Renaming it changed which preset // the creator flow stages and which id the roster reports. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, + { file: 'packages/preset/agent-presets/tests/shipped-root.spec.ts', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', upstream: ['cordis'] }, @@ -196,23 +197,6 @@ const EXACT_EDITS: readonly ExactEdit[] = [ errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`, expect: 1, }, - { - // The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it. - id: 'knip-logger-console', - file: 'knip.json', - find: ` "ignoreDependencies": [ - "@cordisjs/plugin-logger-console", - "@deepseek-ai/.+" - ] - }, - "packages/util/home": {`, - replace: ` "ignoreDependencies": [ - "@deepseek-ai/.+" - ] - }, - "packages/util/home": {`, - expect: 1, - }, { id: 'knip-bundle-base', file: 'knip.json', @@ -348,7 +332,7 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ id: 'vendoring-cookbook-name-invariant-zh', file: 'docs/cookbook/adding-a-vendored-package.zh.md', find: '保留上游的 `name`/`version`/`exports`/`type`', - replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`', + replace: '改写 `name` 的 scope([映射](../rescope.zh.md)),保留上游的 `version`/`exports`/`type`', expect: 1, }, { From d97e3983832d320b9a869db07c94bccaa34b6f87 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 20 Aug 2026 22:01:50 +0800 Subject: [PATCH 006/138] fix(jsonl): warn when repairing torn tails --- packages/session/session-persistence-jsonl/src/index.ts | 1 + packages/session/session-persistence-jsonl/tests/zstd.spec.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 5113746fec..f8c1a86bef 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -441,6 +441,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) + if (tornMarker !== undefined) this.ctx.logger.warn(`${this.name}: session "${meta.id}" recovered from a torn tail; incomplete tail bytes were discarded`) } /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b9cced0087..01695e3488 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -539,6 +539,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('recover-torn', '/proj') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await ctx.sessionPersistence.create(header) await ctx.sessionPersistence.append(header.id, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') @@ -562,6 +563,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { expect(loaded.events.some(event => event.type === 'assistant/chunk' && event.seq === 8)).toBe(false) expect(loaded.events[8]?.type).toBe('step/end') expect(loaded.events[9]?.type).toBe('turn/end') + expect(warn).toHaveBeenCalledWith('session-persistence-jsonl: session "recover-torn" recovered from a torn tail; incomplete tail bytes were discarded') const repaired = await readFile(path) expect(repaired.subarray(0, committed.length)).toEqual(committed) From 05daf25e106ead8e798cda1b5ba4ffd222e5c357 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 10:17:11 +0800 Subject: [PATCH 007/138] test(llm): pin includeShippedRoot off in the inventory roster The spec landed on master before the roster gained the plugin-bundled shipped root; its empty-roots harness now opts out explicitly, matching every other exact-roster suite. --- .../plugin-package-inventory-deepseek/tests/inventory.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index aabbe7362f..d6662caa2f 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -44,7 +44,7 @@ async function harness(enabled?: boolean): Promise<{ ctx: Context; root: string; await ctx.plugin(Loader) ctx.loader.builtins.include = Include await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentPresets, { default: 'fixture', roots: [], includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'fixture', roots: [], includeShippedRoot: false, includeUserRoot: false }) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) const inventory = enabled === undefined ? ctx.plugin(PluginInventory) From 64c9e4a22caa446e6200fd850c01a54b614c18ac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:10:59 +0800 Subject: [PATCH 008/138] docs: define client-derived tool presentation --- ...client-derived-tool-presentation.i18n.yaml | 6 + ...-08-23-client-derived-tool-presentation.md | 696 ++++++++++++++++++ ...-23-client-derived-tool-presentation.zh.md | 696 ++++++++++++++++++ AGENTS.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 8 +- docs/cookbook/adding-a-tool.zh.md | 8 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 6 +- docs/module-graph.zh.md | 6 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 2 +- docs/subsystems/session.zh.md | 2 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + packages/client/AGENTS.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 6 +- packages/client/ui-tool/README.zh.md | 6 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 10 +- packages/core/tools/README.zh.md | 10 +- packages/core/tools/src/index.ts | 7 +- .../src/client/api-catalog.ts | 10 +- .../extensions/tool-cordis/src/api-catalog.ts | 12 +- 38 files changed, 1485 insertions(+), 90 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml new file mode 100644 index 0000000000..8245fb8396 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.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-23-client-derived-tool-presentation.md +2026-08-23-client-derived-tool-presentation.md: 3643c003058f2c17c297606357e35fb43f2e5280 +2026-08-23-client-derived-tool-presentation.zh.md: 31d3a9f082b937dd8d8002bfbc30ef85200ba367 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md new file mode 100644 index 0000000000..3643c00305 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -0,0 +1,696 @@ +# Agent Note: Client-Derived Presentation from Raw Session Tool Events + +Status: implemented + +English | [中文](2026-08-23-client-derived-tool-presentation.zh.md) + +## Problem + +Session history is a durable journal interface, while tool cards are Client presentation. Computing card views during `page` or `follow` would couple history reads to the Tools registry, Agent presets, restored scopes, presenter execution, and transient UI types. + +A `tool/result` does not repeat the tool name or arguments. Host-side result presentation therefore requires either a call index or a backward scan by `callId`; repeated scans over a tool-dense page can approach quadratic work because `maxMessages` does not directly bound the event count. + +Host projection would also duplicate structured data. Read, diff, search, and web results already persist bounded facts in `tool/result.data.meta`; another view object increases Remote payload size and Client decoding without adding durable meaning. + +The Client already owns a complete tool-presentation entry point. `ui-chat` assembles `tool/call`, `tool/result`, and Code Dispatch events into stable `ToolCallBlock` values. `ui-tool` owns the recursive call tree, the `tool.call.toolview` keyed slot dispatched by tool name, the Generic fallback, card models, and details output. A business Client plugin can register a renderer for its own tool names. + +Splitting presentation between Host presenters and Client keyed renderers creates two interpretations of the same event. The keyed renderer is the Web extension point, so an intermediate Host view provides no independent Web capability. + +`ToolDefinition.presentCall` and `presentResult` remain useful Host APIs even though ACP is automation-only and the repository has no production TUI consumer. Removing their definitions is a separate decision from keeping Session reads independent of presentation. + +The required result is one raw Session journal and one Client presentation owner without visual degradation or incidental enhancement. Specialized cards, interactions, and Code Dispatch topology remain stable while the transport stops carrying transient views. + +## Decision + +The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view. + +The Client Conversation layer continues to own tool call/result identity, pairing, lifecycle, Code Dispatch topology, and stable Chat Nodes. It does not interpret individual tool names or produce terminal, diff, read, search, or web component props. + +Client `ui-tool` continues to own card models and concrete renderers. Each card model directly reads the tool name, raw arguments, result content, error, durable metadata, Session cwd, and Host home from `ToolCallBlock`, and produces the same component props as the current page. + +The Client has no second presenter registry. Tool-name dispatch uses only the existing `tool.call.toolview` keyed slot. Pure Client card-model helpers are renderer implementation details, not a Cordis service, public registry, or wire DTO. + +The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCallView`, `ToolResultView`, and existing tool presenter implementations remain. The Session Controller does not invoke them, and the Client does not import or consume them. A future non-Client consumer is outside this decision. + +`ToolOutputDefinition.presentationMeta` and durable `tool/result.data.meta` remain. They carry execution-result facts required by existing specialized cards that the model-visible result text cannot represent losslessly. The Client validates and consumes `meta` directly rather than requiring the Host to convert it into a view during history reads. + +### Goals and non-goals + +| Category | Decision | +|---|---| +| Absent | `SessionEventEntry.view`, `SessionToolView`, and `SessionToolCallView` | +| Absent | `viewFor`, `backscanArgs`, `parseToolCall`, `jsonView`, and presenter-scope lookup from `history.ts` | +| Absent | `openCalls` and fallback event scans used only for follow presentation | +| Absent | the Client Session's parallel `views` array, Conversation input `view`, and Tool block `callView`/`resultView` | +| Derived | terminal, diff, read, search, and web card models read raw blocks and metadata | +| Derived | Deliverables reads successful mutation names and arguments | +| Retained | Host `ToolDefinition.presentCall`/`presentResult` APIs, types, implementations, and direct tests | +| Retained | `output.presentationMeta` and durable `tool/result.data.meta` | +| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology | +| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure | +| Forbidden | a new Client presenter service, parallel registry, or wire renderer id | +| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements | +| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field | + +## Terminology + +**Raw Session event** means a `SessionEvent` fact from the durable log, including the `name` and raw `arguments` string on `tool/call`, and the `content`, `isError`, structured error, and optional `meta` on `tool/result`. + +**Durable metadata** means the JSON value produced by `ToolOutputDefinition.presentationMeta` after a tool succeeds and stored in `tool/result.data.meta`. It is part of the result facts, not a pre-laid-out React or card DTO. + +**Host tool view** means the `ToolCallView` or `ToolResultView` returned by `ToolDefinition.presentCall` or `presentResult`. Session Remote does not transport it. + +**Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`. + +**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row. + +**Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls. + +**No enhancement** means that this decision does not give an input pinned to Generic fallback a new specialized card or expand an existing card's data or interactions. + +## Architecture and Ownership + +### Tool execution and persistence + +1. A tool registers `output.schema`, `output.render`, and optional `output.presentationMeta`. +2. Successful execution produces a canonical JSON value. +3. The Tools runtime snapshots, schema-validates, and freezes the value. +4. `output.render(args, value)` produces model-visible `ContentBlock[]`. +5. When a top-level call declares `output.presentationMeta`, the runtime also produces JSON-safe metadata. +6. The agent loop writes the model-visible result and metadata into a `tool/result` Session event. +7. The Session log does not store `ToolCallView` or `ToolResultView`. + +### Host journal reads + +1. `session.page` obtains attached or persisted events. +2. `paginate()` cuts pages on append-origin user/assistant message boundaries. +3. A tail page obtains its baseline from the registered projection snapshot/restore path. +4. Every page entry contains only `{event}`. +5. `session.follow` establishes its listener before catch-up reads, emits the opening cursor, and then streams contiguous `{event}` frames. +6. Neither path resolves a preset or Tools scope for presentation, parses tool arguments, invokes presenters, or indexes calls. + +### Client data and presentation + +1. The Client Session stores one contiguous raw event window. +2. `SessionEventSource` publishes `SessionEventEntry` values containing only events. +3. `ui-conversation` folds each event without a presentation companion. +4. The Chat and Trajectory Tool Definitions pair top-level calls and results by callId and assemble Code Dispatch subtrees. +5. `RunningToolCall` and `ToolResultNode` retain raw facts, metadata, and existing parent identity. +6. `ToolCallTree` dispatches `tool.call.toolview` by wire tool name. +7. `ui-tool` derives card component props from the block at the render site. + +### Production consumer audit + +| Object | Producer | Production consumer | Decision | +|---|---|---|---| +| `presentCall`/`presentResult` | Host tools | non-Client callers, if any | retained outside Session Remote | +| `SessionEventEntry.view` | none | none | absent from the wire | +| `callView`/`resultView` | none | none | absent from the Client model | +| `presentationMeta` | Tools runtime | `tool/result`, Client card models, and Host presenters | retained durable input | +| fixture presenter mirror | none | none | fixtures send raw metadata | + +ACP does not consume a Session tool view or map Host render intent. The repository has no production TUI consumer. Host presenters remain available without making Session Remote their transport. + +## Data Flow + +```text +Tool execute + -> canonical value + -> output.render(args, value) + -> model-visible result content + -> output.presentationMeta(args, value), when declared + -> durable tool/result event + +Session page/follow + -> raw Session event envelope + -> no tool lookup + -> no preset lookup for presentation + -> no call backscan + -> no render-intent serialization + +Client SessionEventSource + -> Conversation Tool Definition + -> root call/result pairing + Code Dispatch topology + -> ToolCallBlock(name, argsRaw, content, error, meta) + -> tool.call.toolview keyed dispatch + -> Client card model + -> existing React component +``` + +This path retains one durable metadata projection because it runs while the canonical result is still in memory. It removes the second presentation projection performed while reading history. + +### Layer responsibilities + +| Layer | Owns | Does not own | +|---|---|---| +| Tools runtime | execution, canonical value, model text, replayable metadata | Web card selection and component props | +| Session log | durable facts, ordering, replay | transient card DTOs | +| Session Controller | addressing, authority, cold reads, pagination, follow, projection baseline | tool lookup, presenters, presentation scope | +| Client Session | Remote journal lifecycle and contiguous window | tool meaning and card types | +| Conversation Tool Definition | call/result pairing, lifecycle, root/subcall topology | mapping a tool name to a component | +| `ui-tool` | card models, Generic fallback, Chat/Details presentation | Session pagination and the Host registry | +| Business Client plugin | keyed renderer for its own tool name | root/subcall assembly and a global registry | +| `ui-deliverables` | produced paths for current first-party mutations | UI cards or Host render intent | + +## Remote and Durable Data Contracts + +### `SessionEventEntry` + +`SessionEventEntry` remains the journal-entry envelope and contains only `event: SessionWireEvent`. This change does not also turn page entries into bare events or refactor the general `RemoteJournalStream` entry contract. + +`SessionPage.events` remains `SessionEventEntry[]`. + +`SessionFollowFrame` remains either an opening frame or an event frame containing `event`. + +`SessionToolCallView`, `SessionToolView`, and `SessionEventEntry.view` are deleted. + +The Client connection stops re-exporting `ToolCallView` and `ToolResultView` from `dsh-tools/presentation` for Session consumers. + +Generated catalogs and graphs derive the narrowed Remote types and package dependencies from their owning sources. + +### Durable log + +- `tool/call.data.name` remains unchanged. +- `tool/call.data.arguments` remains the model-produced raw JSON string. +- `tool/result.data.message.content` remains the model-visible result. +- `tool/result.data.error` remains the structured failure identity. +- `tool/result.data.meta` remains a tool-private JSON value. +- Client card models do not write to the Session log. +- Renderer keys and Host tool implementation ids do not enter the Session log. +- Existing durable Sessions need no migration, and `SESSION_FORMAT_VERSION` does not change. + +### `presentationMeta` + +`presentationMeta` is not a Host tool view. It reads the canonical value when tool execution completes, and that value is not persisted. Removing it would make the following existing presentation impossible to reconstruct losslessly: + +- read path, offset, lines, totalLines, and lang; +- applied contextual hunks for write/edit; +- grouped grep/glob results, truncation flag, and total; +- web_search source fields and provider answer; +- web_fetch final URL, HTTP status, and effective truncation flag. + +The Client narrows `meta` locally at runtime. Renaming `presentationMeta` to more neutral result metadata is outside this decision. + +## Host Design + +After obtaining source events, `SessionHistoryController.page()` performs only pagination and the existing projection-baseline calculation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set. + +`SessionHistoryController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events. + +The controller has no `presenterScopeFor()`, `viewFor()`, `backscanArgs()`, `parseToolCall()`, or `jsonView()` path. Page state contains no presenter scope or argument resolver; follow state contains no `openCalls`, `fallbackEvents`, or presentation argument resolver. Each page/follow event is wrapped only as `{event}` while addressing, ownership, cursor, sequence, and projection logic remains intact. + +An immutable event-conversion helper may remain narrow or be inlined; its name is irrelevant as long as history performs no presentation work. + +Session Controller dependencies remain only when another package responsibility requires them. Manifest and project references contain no presentation-only dependency. + +### Performance constraints + +- `page()` performs no tool-specific work. +- Adding tool results to a page does not cause repeated scans over existing page events. +- `follow()` maintains no presentation index. +- History does not trigger the Cordis `tools` service proxy. +- History does not wait for a presenter standing scope. +- History does not parse tool-argument JSON. +- History does not perform tool-view JSON clones. +- The Remote payload does not repeat structured data already expressed by `meta`. +- The Client does not scan the complete Session event window to build one card. +- The Client derives a card model again only when the corresponding immutable Tool block changes. + +## Client Session and Conversation + +The Client Session has no private `views` array parallel to the raw event window. `installWindow()`, `prependWindow()`, and `appendLive()` handle only event entries, cursor/hasMore state, queues, projection, and notifications. + +`ConversationEventInput` contains only `event`. The Conversation assembler does not know `SessionToolView`; its replace/prepend/append behavior, Context identity, Location, and publication cadence remain unchanged. + +The Chat and Trajectory Tool Definitions read no views. They derive the following data from events: + +- callId; +- tool name; +- raw arguments; +- turn, step, seq, and time; +- result content; +- isError and structured error; +- result metadata; +- root/subcall parent-child topology; +- synthetic interruption results. + +`RunningToolCall` has no `callView`. + +`ToolResultNode` has no `callView` or `resultView`. + +`ToolCallBlock` does not gain a generic `view`, `card`, `kind`, or `locations` field to replace the deleted fields. Concrete presentation remains the responsibility of `ui-tool` and keyed renderers. + +### Root and Code Dispatch subcalls + +Host presenter APIs describe top-level calls and results. Code Dispatch subcalls use the Generic, flattened Client presentation; recognizing a subcall name does not grant it a structured card. + +Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The five structured card models accept only blocks without `parentCallId`, while existing renderers that intentionally support nested calls continue receiving the same child block. + +The Details panel delegates the selected block unchanged. The same card models observe `parentCallId` and keep a selected Code Dispatch child on the existing raw fallback, so the Details slot needs no placement field. + +The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` controls only the terminal, diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged. + +### Missing call head + +When a result node has no matching call in the current window, `ToolResultNode.call` remains `null`. The Client does not scan the window, issue another RPC, or infer a tool name from result text. + +A specialized derivation that needs the name or arguments uses the current Generic fallback when `call === null`. A model that could use result metadata alone does not gain new presentation, because the current Host `presentResult` must first recover the matching call. + +If a later older page supplies the call head, the Conversation Context rebuilds under existing replay rules and may then produce the already-supported specialized card. + +### Argument and metadata narrowing + +The Client parses JSON from `argsRaw`; a parse failure returns the Generic form instead of throwing a React render error. + +Chat and Details reuse parsing for the same block through pure helpers. Any future cache must use immutable block identity and must not create cross-Session global state keyed by callId. + +Each specialized model checks only the fields it needs. The Client does not copy complete Host tool schemas or invoke a Host `defineTool` validator. + +Valid first-party events must be equivalent to current presenter output. Malformed, old-version, or manually edited logs promise only a crash-free Generic fallback. + +## Client Card-Model Design + +The existing `ui-tool/src/client/tool/models/` directory remains the single source of shared derivation for Chat and Details. Helpers return component props directly; they do not return `ToolCallView` or `ToolResultView`, and they do not create an isomorphic `ClientToolView` union. + +Branches on tool name exist only in `ui-tool` card models, existing row-classification tables, or the Client plugin that owns a keyed renderer for that tool. They must not enter the Session Controller, Client Session, Conversation assembler, or generic Slot renderer. + +Unknown tools continue to use `GenericToolCard` with the name, raw arguments, result content, and error. + +### Generic tool row + +`toolRowModel()` derives the generic row directly from `toolName`, `argsRaw`, result content, error, cwd, and home. It preserves: + +- classification into `search`, `read`, `bash`, `write`, `edit`, `code`, and `others`; +- existing titles and tool-specific titles; +- summary-field priority and single-line truncation; +- comma joining of multiple queries; +- cwd-relative paths and home abbreviation; +- file-path clicks; +- pretty JSON arguments and non-JSON raw-text fallback; +- flattened result content and structured-error fallback; +- running, ok, error, and stopped states. + +The title, kind, rawInput, content, and locations from Generic Host `presentCall` do not currently drive an ordinary Web row. Generic `presentResult.content` also does not drive Web output, so the Client need not copy these unconsumed values. + +### Terminal card + +The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, existing `parentCallId`, and Session cwd. + +| Input | Preserved result | +|---|---| +| running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state | +| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot | +| `run_in_background:true` | Generic row and raw result | +| tool execution error | Generic IN/OUT and error summary | +| running persistent `bash`/`pwsh` | terminal prompt | +| settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card | +| foreground `terminal_send` | terminal prompt and output | +| background/error `terminal_send` | Generic result | +| Code Dispatch child | current flattened Generic form | + +Standard shell results continue parsing trailing `[exit code: N]` and `[killed by signal: X]` markers. A parsed marker is removed from the body; timeout, sandbox denial, and markers without a pill remain in the body. + +Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots. + +Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation. + +`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged. + +### Diff card + +| Input | Preserved result | +|---|---| +| running `write` | intended added-only diff from `file_path` and `content` | +| running `edit` | intended replacement diff from `file_path`, `old_string`, and `new_string` | +| successful settled `write`/`edit` | applied contextual hunks from `meta.diffs` | +| write create or missing/malformed/empty applied metadata | current argument fallback | +| error, malformed arguments, edit with malformed metadata, or Code Dispatch child | Generic | + +Paths, `oldText:null`, `newText`, result-over-call diff precedence, the eight-line Chat limit, full-height Details presentation, and file-opening behavior remain unchanged. + +### Read card + +A running `read` continues to show only the summary row. A successful settled `read` reads path, offset, lines, totalLines, and lang from result metadata and confirms that the result is one text block matching the read envelope. + +Missing metadata, malformed fields, a mismatched result envelope, an error, a missing call head, or a Code Dispatch child all use Generic. Cwd-relative path labels, home abbreviation, syntax language, total line count, the eight-line Chat limit, and full-height Details presentation remain unchanged. + +The Client does not need to construct Host `ReadResultView.content`; Generic fallback can always read raw result content directly. + +### Search card + +A running `grep` or `glob` continues to show only the argument summary. Successful results produce grouped matches or a path list from `meta.shape:'matches'` and `meta.shape:'paths'`, respectively. + +The Client validates path, lineNumber, line, truncated, and total. Empty matches or paths form a valid card. Missing or malformed metadata, an unknown shape, an error, a missing call head, or a Code Dispatch child uses Generic. + +When `truncated:true`, the card continues to show a recovery locator from raw result content. It does not show one when untruncated. The eight-line Chat limit, full-height Details presentation, and expansion behavior remain unchanged. + +### Web card + +A running `web_search` or `web_fetch` continues to show only the summary row. A successful search builds the card from `meta.sources`, `meta.answer`, and `meta.truncated`; a successful fetch builds it from `meta.url`, `meta.statusCode`, and `meta.truncated`. + +The Client validates every source's url, title, snippet, and publishedAt, and continues rendering only http/https URLs as links. Missing or malformed metadata, an error, a missing call head, or a Code Dispatch child uses Generic. + +Search answer text, source ordering, label fallback, and truncation notice remain unchanged. The fetch final URL, status, truncation notice, and raw body below Details remain unchanged. + +### Renderers already using raw blocks + +- Todo rows continue deriving completed/active summaries from arguments. +- Question rows continue deriving waiting, answered, cancelled, and interrupted states from result content and errors. +- Skill rows continue deriving names and states from calls and results. +- Cordis define/run/action rows continue deriving from calls, results, and their own Client services. +- These renderers retain their props, slot keys, registration order, and visible results. + +## Deliverables + +`ui-deliverables` derives mutation business facts independently of presentation intent, so produced-file behavior is not coupled to card screenshots. + +The Deliverables Definition observes root `tool/call` and successful `tool/result` events by callId and retains a minimal Client-owned mutation candidate without scanning the Session window or depending on a UI renderer. + +| Tool | Mutation condition | Path source | +|---|---|---| +| `write` | any successful call | `file_path` | +| `edit` | any successful call | `file_path` | +| `str_replace_editor` | `create`, `str_replace`, or `insert` | `path` | +| `str_replace_editor` | `view` | produces no path | +| Other | no current first-party mutation semantics | produces no path | + +Failures, interruptions, orphan results, missing paths, and malformed arguments produce no deliverable. Paths retain first-seen deduplication, and results settled after the closing Assistant seq remain excluded. + +This change does not add a general tool-side-effect registry. The ability for a Host-only third-party presenter to join Deliverables automatically through `kind:'edit'` or `locations` is intentionally removed. A future real third-party mutation requirement must use a Client business contribution and cannot restore Session views. + +## Fixtures and Test Data + +The Client fixture deletes its handwritten `presentCall()`, `presentResult()`, `viewFor()`, and fixture tool-view types. It continues producing the same raw calls, result content, and result metadata as a real log. + +| Fixture | Raw facts that must remain | +|---|---| +| terminal | arguments and real result status markers | +| diff | arguments and result `meta.diffs` | +| read | result metadata path/offset/lines/totalLines/lang | +| grep/glob | result metadata shape/files or paths/truncated/total | +| web | result metadata sources/answer or url/statusCode/truncated | +| generic/custom | name, argsRaw, content, and error | + +The fixture does not import Host tool packages to compute page presentation and retains no presenter mirror. The same raw fixture continues to drive jsdom, built Web snapshots, and the `?fixture` browser path. + +## Presentation-Equivalence Matrix + +“Current presentation” is defined by committed component tests, assembly tests, and Web browser expected outputs. A transport or ownership refactor does not justify refreshing snapshots; an approved product change requires separate evidence. + +| Scenario | Required presentation | +|---|---| +| unknown tool, running | Generic row with tool name and argument summary | +| unknown tool, settled | Generic row and raw output | +| malformed arguments | safe Generic fallback | +| orphan result | callId title and Generic output | +| interrupted call | warning/stopped state | +| foreground bash/pwsh | current terminal prompt, body, cwd, and state | +| background/error bash/pwsh | current Generic IN/OUT | +| persistent shell | current running terminal and settled Generic form | +| terminal_send | current foreground terminal and background/error Generic form | +| write/edit | current intended/applied diff and error fallback | +| read | current running summary, settled ReadBlock, and error fallback | +| grep/glob | current grouped/path card, truncation, and recovery | +| web_search/web_fetch | current source/summary card and raw body | +| Todo/Question/Skill/Cordis | current specialized rows | +| Code Dispatch subcall | current Generic/flattened form | +| Chat and Details | identical card fields for the same call | +| Trajectory | current identity, tree, selection, and details | +| Deliverables | current successful-mutation chips and links | + +## Client Extension Contract + +`tool.call.toolview` remains the sole tool UI registration mechanism. A tool that needs specialized Client presentation must have a Client plugin register its wire tool name. + +The registrant receives the raw `ToolCallBlock`, Session path information, and host actions, and validates the argument and metadata fields it recognizes. It does not call the Host tool registry, depend on `presentCall` or `presentResult`, or require `SessionEventEntry.view`. + +A tool with no Client renderer consistently degrades to Generic. Only one keyed registration for a tool name can be active, and duplicate keys continue to fail loudly. + +A Session-scoped slot can express Client-side Session differences, but no renderer variant is inferred from a preset. A Host-only presenter does not grant a Web rich card automatically. This is the explicit boundary between “the Host describes presentation” and “the Client plugin owns presentation.” + +## Failures and Fallback + +- The Client treats arguments and metadata as wire JSON and narrows them at the consumption site. +- Argument JSON parse failure uses Generic. +- A known tool missing required fields uses Generic. +- Missing or malformed metadata uses Generic, except successful `write`, whose current presenter preserves its argument-derived whole-file diff. +- An error result does not show a success card merely because metadata is present. +- A missing call head does not trigger guesses about the tool name or arguments. +- Unknown metadata fields are ignored. +- A new metadata variant uses Generic in an older Client. +- Card-model helpers catch expected parse failures instead of relying on a React error boundary for ordinary fallback. +- Unexpected failures inside a keyed renderer remain isolated by existing Slot error handling. + +## Same-Named Host Providers + +The Host registry allows different scopes to provide different definitions under the same tool name. Through presenter scope, a Session view can theoretically select a different render intent by preset. After removing the view, the Client keyed slot observes only the wire name and cannot observe Host definition identity. + +The notable current first-party examples are standard and persistent `bash` and `pwsh`. Client derivation uses valid argument and result features to preserve their delivered differences without a provider-id wire field. Malformed or custom same-name provider input that cannot be distinguished uses Generic. + +This change does not promise to preserve differences expressed only through a Host presenter by third-party same-name providers. If the product later requires distinct Client presentation for same-name providers, it must define a stable, non-presentational Client identity and must not restore per-page Host view computation. + +## Shipped Scope + +### Session Controller + +- `SessionEventEntry` contains only the raw event. +- Both Session tool-view types are absent. +- History has no presentation imports, helpers, or page/follow presentation state. +- Addressing, pagination, follow, and projection logic remain in the Session owner. +- Host tests assert the raw journal contract. + +### Session Controller Client + +- `Session.views` is absent. +- EventSource replace/prepend/append deltas remain unchanged. +- Transport, fixture, and test-support types carry raw entries. +- Event identity and reference stability remain unchanged. + +### UI Conversation, Chat, and Trajectory + +- Conversation input and Tool blocks contain no view fields. +- Chat and Trajectory Tool Definitions read raw events. +- Event pairing, Context replay, trees, and target snapshots remain unchanged. +- Child Tool blocks preserve the existing Code Dispatch `parentCallId`; row and Details slot owner props add no separate placement field. + +### UI Tool and Deliverables + +- Card models derive from raw blocks and metadata. +- Chat and Details share the same helpers. +- Generic fallback and keyed dispatch remain unchanged. +- Deliverables recognizes first-party mutation arguments. + +### Fixtures, documentation, and generated artifacts + +- Fixtures send only raw events and metadata. +- Session Controller and Client README/JSDoc contracts describe the raw journal and Client presentation owner. +- The tool cookbook documents the Web Client integration path. +- This Agent Note is the decision owner; retained Host presenter notes keep their independent decisions. +- Authored Remote types, dependencies, READMEs, pairing records, and generated references remain synchronized. + +## Verification Matrix + +### Host + +- page returns contiguous raw event entries. +- follow returns an opening cursor and contiguous raw event entries. +- page/follow behave identically without the Tools service. +- A cold page does not resolve or mount a preset. +- A tail page computes its baseline through the standard projection registry; provider availability follows the projection composition rather than a history-side setup path. +- Addressing, ownership, message-aligned boundaries, and tail projection remain unchanged. +- Listener-before-read, reconnect catch-up, and gap repair remain unchanged. +- Many tool results do not trigger a backscan per result. +- Wire results contain no view. + +`session-history-journal.host.spec.ts` owns pagination, continuity, and history error behavior without presenter assertions. + +### Client Conversation + +- replace, prepend, and append accept entries without views. +- Chat and Trajectory root call/result pairing remains unchanged. +- The Code Dispatch tree remains unchanged. +- Result-only fallback remains unchanged. +- A synthetic interruption result copies no view. +- Node identity across registry rebuild, older prepend, and live append remains unchanged. + +### Client card model + +- terminal produces the pinned props from raw arguments/content. +- diff produces the pinned diffs from arguments/metadata. +- read produces the pinned lines from metadata/content. +- search produces the pinned grouped/path card and recovery from metadata/content. +- web produces the pinned sources/fetch summary from metadata/content. +- unknown, malformed, error, missing-call, and missing-metadata cases remain Generic. +- absent and present `parentCallId` cases prove that structured presentation does not reach Code Dispatch descendants. +- Chat and Details produce identical card fields for the same block. + +### Deliverables + +- Successful write/edit calls produce `file_path`. +- str_replace_editor create/str_replace/insert calls produce `path`. +- str_replace_editor view produces no path. +- failure, interruption, malformed input, and orphan results produce no path. +- First-seen deduplication and the closing-seq cutoff remain unchanged. + +### Assembly and browser + +- terminal, diff, read, search, and web browser expected outputs all pass without refresh. +- Visible assertions for the tool tree, details, trajectory, and deliverables retain their expected values. +- The built Client still displays the same cards after obtaining raw events from real Remote page/follow operations. +- Fixtures and the real Host use the same Client derivation. +- A minimal preset independently pins persistent-shell behavior. + +### Static and documentation + +- Production code contains no `SessionToolView` or `SessionToolCallView`. +- Session history does not reference `dsh-tools/presentation`, `ctx.tools`, `presenterScopeFor`, or `backscanArgs`. +- Client Conversation does not reference `ToolCallView` or `ToolResultView`. +- Client models do not read `callView` or `resultView`. +- The fixture defines no presenter mirror. +- Host `presentCall`, `presentResult`, and `presentationMeta` remain. +- No new Client registry or Host-to-Client presentation hint exists. +- Affected authored types, READMEs, Agent Notes, catalogs, and graphs are synchronized. + +## Verification Commands + +Changes to this decision use `dsh-pre-push-checks` to select commands for the final diff. Required evidence includes: + +- focused Session Controller history/transport tests; +- ui-chat and ui-trajectory Tool Definition tests; +- ui-tool terminal, diff, read, search, web, row, tree, and details tests; +- ui-deliverables produced-file tests; +- connection fixture and Client runtime tests; +- affected Host and Client TypeScript faces; +- lint and duplication; +- per-file 100% coverage for affected source files; +- `DSH_SNAPSHOT=replay pnpm run test:web`, without refreshing existing presentation goldens; +- authored Remote type and TypeScript checks; +- `pnpm run doc-sync`; +- `git diff --check`. + +## Shipped Invariants + +- Session page/follow does not read the Tools registry or a presenter scope. +- Session history has no callId backscan, presentation cache, or view clone. +- A Remote Session entry carries no view. +- The Session log and `SESSION_FORMAT_VERSION` remain unchanged. +- Result metadata passes byte-for-byte through the log and Remote to the Client. +- Conversation assembles `ToolCallBlock` only from raw events. +- `ToolCallBlock` contains no Host render-intent fields. +- The five structured card models read only raw blocks, their existing `parentCallId`, and Session path facts. +- Generic, Todo, Question, Skill, and Cordis rows remain unchanged. +- Deliverables does not depend on render intent and preserves current paths. +- Text, components, expanded content, states, links, and ordering for all first-party top-level tools remain unchanged. +- Malformed, missing-metadata, error, orphan, and unknown-tool cases continue to fall back safely. +- Code Dispatch subcalls remain Generic and flattened. +- Chat, Details, and Trajectory behavior remains unchanged. +- Existing Web browser expected outputs pass without refresh. +- Host presenter APIs, implementations, and direct tests remain unchanged. +- ACP output remains unchanged. +- No new downstream presentation field or second Client registry is introduced. +- Pagination cost no longer grows as the number of results multiplied by page event count. +- Downstream payloads no longer duplicate result metadata in a card DTO. + +## Alternatives considered + +### Optimize only `backscanArgs` and retain views + +Building one `callId → {name,args}` Map before processing a page would make backscan linear, and live follow already has an `openCalls` fast path. It would leave Host lookups, preset scopes, presenters, JSON clones, duplicate payloads, and dual ownership intact, so this alternative is rejected. + +### Add a presenter registry to the Client + +Copying the `presentCall` and `presentResult` interfaces into the browser would duplicate the registration, lifecycle, fallback, and override semantics of the `tool.call.toolview` slot. Renderers would still have to convert presenter DTOs into component props, so this alternative is rejected. + +### Have the Conversation Tool Definition produce one unified view + +This would put tool names and UI-card semantics into the target-neutral Conversation owner and recreate an intermediate DTO isomorphic to the Host view, so this alternative is rejected. + +### Delete `presentationMeta` + +Read line structure, applied diffs, search grouping, web sources, and effective truncation cannot be recovered losslessly from model text. Parsing free-form text would also bind the UI to output wording, so this alternative is rejected. + +### Persist canonical tool results + +This would enlarge the Session log, expose internal result structures, change the durable format, and potentially store objects far larger than presentation requires. Existing metadata is sufficient, so this alternative is rejected. + +### Delete Host presenter APIs + +Deleting them would shrink more code, but the decision preserves Host `presentCall` and `presentResult`. Their APIs, implementations, tests, and types remain independent of Session Remote. + +### Import Host tool implementations into the Client + +Tool packages include Node, filesystem, subprocess, or provider dependencies and cannot enter the browser bundle. The Client consumes only raw JSON and maintains narrow parsers inside its own renderers, so this alternative is rejected. + +### Query presentation from the Host per result + +An on-demand RPC would turn one page read into N network calls and would still require Host lookups, scope restoration, callId recovery, and error coordination, so this alternative is rejected. + +### Allow presentation enhancements + +The Client could produce more rich cards for Code Dispatch subcalls, missing call heads, or history whose Host presenter was unavailable. That would mix an ownership change with product behavior and prevent snapshots from proving equivalence, so this alternative is rejected. + +### Accept temporary Generic degradation + +Stopping view delivery before completing Client cards would temporarily degrade terminal, diff, read, search, web, and Deliverables behavior. Client-equivalent derivation and Host removal must land in the same releasable change. + +## Consequences + +The decision removes presentation work, repeated scans, and duplicate view payloads from Session reads. Its cost is that the retained Host presenter and Client card derivation can evolve independently, so both sides require owner-specific tests and Web equivalence remains an explicit product constraint. + +### Client and Host logic drift + +Each tool may have one Host render intent and one Client card derivation. They serve different consumers and do not share a runtime path. Unrefreshed browser expected outputs pin visual equivalence for the first-party Web experience, while Host presenter tests constrain only the Host API. + +### Same-named providers lack stable identity + +A raw event records the tool name but not the specific ToolDefinition. The Client uses valid event fields to preserve differences between standard and persistent shells. Ambiguous custom or malformed input falls back to Generic; the wire has no extra hint for theoretical extensibility. + +### Metadata is unknown JSON + +Old Sessions may lack fields, and manually edited logs may contain malformed values. Each Client model must narrow locally and cannot pass unknown arrays or objects directly into UI primitives. + +### Two targets must stay synchronized + +Chat and Trajectory have separate Tool Definitions and both carry the raw fields. Card derivation remains only in `ui-tool` and cannot be copied into either Definition. + +### Deliverables has a hidden dependency + +Deliverables is not a visual component, so its mutation parser must remain synchronized with supported first-party write tools. Dedicated tests pin file chips and Markdown links independently of card screenshots. + +### Fixtures can create false confidence + +Fixtures send raw events and metadata rather than handwritten views. Real-Host assembly coverage remains necessary because fixture-only snapshots cannot prove the transport path. + +### Incorrectly refreshing snapshots + +This change promises unchanged user-visible output. A snapshot difference must be fixed in Client derivation. Expected outputs must not be refreshed unless the owner separately approves a specific visual change. + +### Documentation drift + +The Agent Note, package READMEs, cookbook, root rules, and generated references must change together whenever the raw journal or Client presentation owner changes. Host API documentation remains separate. + +### Remote protocol narrowing + +The absence of optional `view` is a prerelease wire-type decision shared by all consumers. There is no compatibility shim, dual-writing, or version negotiation. + +## Relationship to Existing Decisions + +This note partially supersedes the implementation fact in [Client tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) that “card models receive Host views.” Its core decisions remain: `ui-tool` owns presentation, business plugins use keyed slots, and Conversation owns only lifecycle and topology. + +This note preserves [toolview dissolution](2026-07-23-toolview-dissolution.md): the Client still has one slot registration model and does not restore `ToolViewRegistry`. + +This note narrows the consumer scope of the [render-intent union](2026-07-02-tool-render-intent-union.md). The Host APIs and types remain, while the Session Remote and Web Client do not consume them. This note owns the transport split without rewriting that presenter decision. + +This note updates the entry contract from [Session history and Remote event transport](2026-08-18-session-history-and-event-transport.md): the journal transports only raw events plus an independent projection baseline, not transient tool views. + +This note follows [Conversation Node assembly](2026-08-09-client-conversation-node-assembly.md): the Tool Definition owns event pairing and the call tree, while concrete card models remain in `ui-tool`. + +This note preserves result metadata from the [canonical tool output contract](2026-07-20-canonical-tool-output-contract.md), because it is the lossless, replayable input to Client derivation. + +## Deferred + +- A separate explicit decision may evaluate deleting Host presenters if they remain without production consumers; this decision does not prejudge it. +- Specialized cards for Code Dispatch subcalls require a separate design and visible-snapshot updates; this decision preserves current behavior. +- A third-party mutation tool that joins Deliverables requires a new Client-owned contribution; this decision does not create a registry for an absent consumer. +- Distinct Client presentation for same-named providers first requires a stable, non-presentational identity; it must not restore per-page Host views. +- If Client card-model performance needs measurement, an immutable-block microbenchmark can be added; the shipped architecture already prohibits scanning the Session window. diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md new file mode 100644 index 0000000000..31d3a9f082 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -0,0 +1,696 @@ +# Agent Note: Client 从原始 Session 工具事件派生展示 + +Status: implemented + +[English](2026-08-23-client-derived-tool-presentation.md) | 中文 + +## Problem + +Session 历史是持久 journal 接口,工具卡片属于 Client 展示。在 `page`/`follow` 中计算卡片 view 会让历史读取依赖 Tools registry、Agent preset、恢复后的 scope、presenter 执行和临时 UI 类型。 + +`tool/result` 不重复记录工具名称和参数。Host 端结果展示因此需要 call index 或按 `callId` 回扫;`maxMessages` 不直接限制事件数量,工具密集页面上的重复扫描可能接近二次方成本。 + +Host 投影还会重复结构化数据。read、diff、search 与 web 结果已在 `tool/result.data.meta` 中持久化有界事实;另一份 view 只增加 Remote payload 与 Client 解码成本,不增加持久语义。 + +Client 已经拥有完整的工具展示入口。`ui-chat` 将 `tool/call`、`tool/result` 与 Code Dispatch 事件组装成稳定的 `ToolCallBlock`;`ui-tool` 拥有递归调用树、按工具名称分发的 `tool.call.toolview` keyed slot、Generic fallback、卡片模型和 details output;业务 Client 插件可以为自己的工具名称注册 renderer。 + +Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的两套解释。keyed renderer 是 Web 扩展点,因此中间 Host view 不提供独立 Web 能力。 + +`ToolDefinition.presentCall`/`presentResult` 仍是保留的 Host API;ACP 采用 automation-only 协议,仓库也没有生产 TUI consumer。是否删除这些定义与 Session 读取是否独立于展示是两个决定。 + +所需结果是一条原始 Session journal 和一个 Client 展示 owner,且不发生可见退化或顺带增强。专用卡片、交互和 Code Dispatch 拓扑保持稳定,transport 不再携带临时 view。 + +## Decision + +Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。 + +Client Conversation 层继续负责工具调用与结果的 identity、配对、生命周期、Code Dispatch 拓扑和稳定 Chat Node。它不解释具体工具名称,也不生成 terminal、diff、read、search 或 web 组件 props。 + +Client `ui-tool` 继续负责 card model 和具体 renderer。每个 card model 改为直接读取 `ToolCallBlock` 中的工具名称、原始参数、结果内容、错误、持久 metadata、Session cwd 与 Host home,并生成与现有页面相同的组件 props。 + +Client 不建立第二套 presenter registry。工具名称分发只使用现有 `tool.call.toolview` keyed slot;Client 中的纯 card-model helper 属于 renderer 实现,不成为 Cordis service、公开 registry 或 wire DTO。 + +Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCallView`、`ToolResultView` 及现有 presenter 实现全部保留。Session Controller 不调用它们,Client 不导入或消费它们;未来非 Client consumer 是否使用它们不属于本决定。 + +`ToolOutputDefinition.presentationMeta` 与持久 `tool/result.data.meta` 保留。它们携带模型可见结果文本无法无损表达、而现有专用卡片需要的执行结果事实。Client 直接校验并消费 `meta`,不要求 Host 在历史读取时再把它转换成 view。 + +### 目标与非目标 + +| 类别 | 决定 | +|---|---| +| 不存在 | `SessionEventEntry.view`、`SessionToolView`、`SessionToolCallView` | +| 不存在 | `history.ts` 的 `viewFor`、`backscanArgs`、`parseToolCall`、`jsonView` 与 presenter scope lookup | +| 不存在 | follow 中只服务 presentation 的 `openCalls` 与 fallback event scan | +| 不存在 | Client Session 的平行 `views` 数组、Conversation input 的 `view`、Tool block 的 `callView`/`resultView` | +| 派生 | terminal、diff、read、search、web card model 读取 raw block/meta | +| 派生 | Deliverables 读取成功 mutation 的名称与参数 | +| 保留 | Host `ToolDefinition.presentCall`/`presentResult` API、类型、实现与直接测试 | +| 保留 | `output.presentationMeta` 与持久 `tool/result.data.meta` | +| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology | +| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 | +| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id | +| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强 | +| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 | + +## 术语 + +**原始 Session event**指持久日志中的 `SessionEvent` 事实,包括 `tool/call` 的 `name` 与原始 `arguments` 字符串,以及 `tool/result` 的 `content`、`isError`、结构化错误和可选 `meta`。 + +**持久 metadata**指 `ToolOutputDefinition.presentationMeta` 在工具成功执行时生成并写入 `tool/result.data.meta` 的 JSON 值。它是结果事实的一部分,不是预先排版的 React 或 card DTO。 + +**Host tool view**指 `ToolDefinition.presentCall`/`presentResult` 返回的 `ToolCallView`/`ToolResultView`;Session Remote 不运输它。 + +**Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。 + +**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。 + +**对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。 + +**无增强**指本决定不让被固定为 Generic fallback 的输入获得新专用卡片,也不扩大已有卡片的数据或交互。 + +## 架构与所有权 + +### 工具执行与持久化 + +1. 工具注册 `output.schema`、`output.render` 和可选 `output.presentationMeta`。 +2. 成功执行产生 canonical JSON value。 +3. Tools runtime 对 value 做快照、schema 校验和冻结。 +4. `output.render(args, value)` 生成模型可见 `ContentBlock[]`。 +5. 顶层调用若声明 `output.presentationMeta`,runtime 同时生成 JSON-safe metadata。 +6. Agent loop 把模型可见结果与 metadata 写入 `tool/result` Session event。 +7. Session log 不保存 `ToolCallView` 或 `ToolResultView`。 + +### Host journal 读取 + +1. `session.page` 取得 attached 或 persisted 事件。 +2. `paginate()` 按 append-origin user/assistant message 边界切页。 +3. tail page 通过已注册 projection 的 snapshot/restore 路径取得 baseline。 +4. 每个 page entry 只包含 `{event}`。 +5. `session.follow` 先建立 listener,再执行 catch-up read、发送 opening cursor 并流式下发连续 `{event}` frame。 +6. 两条路径都不为展示解析 preset/Tools scope、解析工具参数、调用 presenter 或建立 call index。 + +### Client 数据与展示 + +1. Client Session 保存一个连续 raw event window。 +2. `SessionEventSource` 发布只含 event 的 `SessionEventEntry`。 +3. `ui-conversation` 在没有 presentation companion 的情况下 fold 每个事件。 +4. Chat 与 Trajectory Tool Definition 按 callId 配对顶层 call/result,并组装 Code Dispatch 子树。 +5. `RunningToolCall` 与 `ToolResultNode` 保存 raw facts、metadata 与既有 parent identity。 +6. `ToolCallTree` 按 wire tool name 分发 `tool.call.toolview`。 +7. `ui-tool` 在 render site 从 block 派生 card component props。 + +### 生产消费者审计 + +| 对象 | 生产者 | 生产消费者 | 决定 | +|---|---|---|---| +| `presentCall`/`presentResult` | 各 Host 工具 | 可能存在的非 Client caller | 保留在 Session Remote 之外 | +| `SessionEventEntry.view` | 无 | 无 | wire 不存在 | +| `callView`/`resultView` | 无 | 无 | Client model 不存在 | +| `presentationMeta` | Tools runtime | `tool/result`、Client card model 与 Host presenter | 保留的持久输入 | +| fixture presenter mirror | 无 | 无 | fixture 下发 raw metadata | + +ACP 不消费 Session tool view,也不映射 Host render intent。仓库没有生产 TUI consumer;Host presenter 保留,但 Session Remote 不作为其 transport。 + +## 数据流 + +```text +Tool execute + -> canonical value + -> output.render(args, value) + -> model-visible result content + -> output.presentationMeta(args, value), when declared + -> durable tool/result event + +Session page/follow + -> raw Session event envelope + -> no tool lookup + -> no preset lookup for presentation + -> no call backscan + -> no render-intent serialization + +Client SessionEventSource + -> Conversation Tool Definition + -> root call/result pairing + Code Dispatch topology + -> ToolCallBlock(name, argsRaw, content, error, meta) + -> tool.call.toolview keyed dispatch + -> Client card model + -> existing React component +``` + +这条链路保留一次持久 metadata 投影,因为它发生在 canonical result 尚在内存时;删除的是读取历史时的第二次展示投影。 + +### 分层责任 + +| 层 | 负责 | 不负责 | +|---|---|---| +| Tools runtime | 执行、canonical value、模型文本、可重放 metadata | Web 卡片选择和组件 props | +| Session log | 持久事实、顺序、回放 | 临时 card DTO | +| Session Controller | 地址、权限、冷读、分页、follow、projection baseline | tool lookup、presenter、展示 scope | +| Client Session | Remote journal 生命周期与连续窗口 | 工具含义、卡片类型 | +| Conversation Tool Definition | call/result 配对、lifecycle、root/subcall topology | 工具名到组件的解释 | +| `ui-tool` | card model、通用 fallback、Chat/Details 展示 | Session 分页与 Host registry | +| 业务 Client 插件 | 自有 tool name 的 keyed renderer | root/subcall 编排与全局 registry | +| `ui-deliverables` | 当前第一方 mutation 的 produced path | UI card 或 Host render intent | + +## Remote 与持久数据约定 + +### `SessionEventEntry` + +`SessionEventEntry` 保留为 journal entry envelope,只含 `event: SessionWireEvent`。本次不顺带把 page entries 改成裸事件,也不重构 `RemoteJournalStream` 的通用 entry 约定。 + +`SessionPage.events` 仍是 `SessionEventEntry[]`。 + +`SessionFollowFrame` 仍是 opening frame 或带 `event` 的 event frame。 + +删除 `SessionToolCallView`、`SessionToolView` 和 `SessionEventEntry.view`。 + +Client connection 不再从 `dsh-tools/presentation` 转出 `ToolCallView`/`ToolResultView` 供 Session 消费。 + +生成 catalog 与 graph 从各自 source owner 派生已收窄的 Remote 类型和 package dependency。 + +### 持久日志 + +- `tool/call.data.name` 保持原样。 +- `tool/call.data.arguments` 保持模型产生的原始 JSON 字符串。 +- `tool/result.data.message.content` 保持模型可见结果。 +- `tool/result.data.error` 保持结构化失败身份。 +- `tool/result.data.meta` 保持工具私有 JSON 值。 +- Client card model 不写入 Session log。 +- renderer key 与 Host tool implementation id 不写入 Session log。 +- 现有持久 Session 无需迁移,`SESSION_FORMAT_VERSION` 不变。 + +### `presentationMeta` + +`presentationMeta` 不是 Host tool view。它在工具执行完成时读取 canonical value,而该 value 不会持久化;删除它会使下列现有展示无法无损恢复: + +- read 的 path、offset、lines、totalLines 与 lang; +- write/edit 的 applied contextual hunks; +- grep/glob 的分组结果、截断标志与总数; +- web_search 的来源字段与 provider answer; +- web_fetch 的最终 URL、HTTP status 与有效截断标志。 + +Client 对 `meta` 做局部运行时收窄。是否把 `presentationMeta` 改名为更中性的 result metadata 不属于本决定。 + +## Host 端设计 + +`SessionHistoryController.page()` 在取得 source events 后只执行分页与现有 projection baseline 计算。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。 + +`SessionHistoryController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。 + +Controller 不存在 `presenterScopeFor()`、`viewFor()`、`backscanArgs()`、`parseToolCall()` 或 `jsonView()` 路径。page state 不含 presenter scope 或参数 resolver;follow state 不含 `openCalls`、`fallbackEvents` 或 presentation 参数 resolver。每个 page/follow event 只包装成 `{event}`,地址、ownership、cursor、seq 与 projection 逻辑保持完整。 + +不可变 event 转换 helper 可以保持窄实现或内联;只要 history 不执行 presentation 工作,其名称没有语义。 + +Session Controller dependency 只在其他 package responsibility 需要时保留;manifest 与 project reference 不含 presentation-only dependency。 + +### 性能约束 + +- `page()` 的工具相关工作为零。 +- 页面增加 tool result 不增加对既有页面事件的重复扫描。 +- `follow()` 不维护展示索引。 +- history 不触发 Cordis `tools` service proxy。 +- history 不等待 presenter standing scope。 +- history 不执行工具参数 JSON parse。 +- history 不执行 tool view JSON clone。 +- Remote payload 不重复携带 `meta` 已表达的结构化数据。 +- Client 不扫描完整 Session event window 生成单个卡片。 +- Client 只在对应 immutable Tool block 变化时重新派生 card model。 + +## Client Session 与 Conversation + +Client Session 不含与 raw event window 平行的私有 `views` 数组。`installWindow()`、`prependWindow()` 和 `appendLive()` 只处理 event entries、cursor/hasMore、queue、projection 与通知。 + +`ConversationEventInput` 只携带 `event`。Conversation assembler 不认识 `SessionToolView`,其 replace/prepend/append、Context identity、Location 与 publication cadence 不变。 + +Chat 和 Trajectory 的 Tool Definition 都不读取 view,而从事件生成以下数据: + +- callId; +- tool name; +- raw arguments; +- turn、step、seq 与 time; +- result content; +- isError 与 structured error; +- result metadata; +- root/subcall parent-child topology; +- interruption synthetic result。 + +`RunningToolCall` 不含 `callView`。 + +`ToolResultNode` 不含 `callView` 与 `resultView`。 + +`ToolCallBlock` 不新增通用 `view`、`card`、`kind` 或 `locations` 字段替代被删除字段。具体展示仍只属于 `ui-tool` 与 keyed renderer。 + +### Root 与 Code Dispatch 子调用 + +Host presenter API 描述顶层 call/result。Code Dispatch 子调用使用 Generic/flattened Client 展示;Client 能识别子调用名称并不赋予它结构化卡片。 + +Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。五类结构化 card model 只接受没有 `parentCallId` 的 block,原本有意支持嵌套调用的 renderer 则继续收到同一个 child block。 + +Details panel 原样委托选中的 block。同一组 card model 读取 `parentCallId`,让选中的 Code Dispatch child 保持现有 raw fallback,因此 Details slot 不需要 placement 字段。 + +keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只控制本决定覆盖的 terminal/diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。 + +### 缺失调用头 + +结果节点在当前窗口没有配对 call 时,`ToolResultNode.call` 保持 `null`。Client 不扫描窗口、不发额外 RPC,也不根据 result 文本猜测工具名称。 + +需要名称或参数的专用派生在 `call === null` 时走当前 Generic fallback。只依赖 result metadata 的模型也不借机增强,因为当前 Host `presentResult` 必须先取得配对调用。 + +older page 后续补入调用头时,Conversation Context 按既有 replay 规则重建,届时才允许生成当前已有的专用卡片。 + +### 参数与 metadata 收窄 + +Client 从 `argsRaw` 解析 JSON,解析失败返回 Generic,不抛出 React render 错误。 + +Chat 与 Details 通过纯 helper 复用同一 block 的解析。未来缓存必须使用 immutable block identity,不能按 callId 建立跨 Session 全局状态。 + +每个专用模型只检查它需要的字段。Client 不复制完整 Host tool schema,也不调用 Host `defineTool` validator。 + +合法第一方事件必须与当前 presenter 输出等价。畸形、旧版本或手工修改日志只承诺不崩溃并使用 Generic fallback。 + +## Client card-model 设计 + +现有 `ui-tool/src/client/tool/models/` 继续是 Chat 与 Details 共享派生的唯一位置。helper 直接返回组件 props,不返回 `ToolCallView`/`ToolResultView`,也不创建同构的 `ClientToolView` union。 + +工具名称分支只存在于 `ui-tool` card model、现有 row 分类表,或拥有该工具 keyed renderer 的 Client 插件;不得进入 Session Controller、Client Session、Conversation assembler 或通用 Slot renderer。 + +未知工具继续由 `GenericToolCard` 显示 name、原始 args、结果 content 与错误。 + +### 通用工具行 + +`toolRowModel()` 直接从 `toolName`、`argsRaw`、result content、error、cwd 与 home 派生通用行,并保持以下行为: + +- `search`、`read`、`bash`、`write`、`edit`、`code` 与 `others` 分类; +- 现有标题与工具专用标题; +- summary 字段优先级和单行截断; +- 多 query 的逗号拼接; +- cwd 相对化与 home 缩写; +- file path 点击; +- args pretty JSON 与非 JSON 原文 fallback; +- result content flatten 与 structured error fallback; +- running、ok、error 与 stopped 状态。 + +Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 当前并不驱动普通 Web 行;Generic `presentResult.content` 也不驱动 Web 输出,因此无需把这些未消费值复制到 Client。 + +### Terminal 卡片 + +Client terminal model 从工具名称、调用参数、结果 content、error、现有 `parentCallId` 与 Session cwd 派生现有 `TerminalBlock` props。 + +| 输入 | 保持的结果 | +|---|---| +| 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 | +| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 | +| `run_in_background:true` | Generic 行与原始结果 | +| 工具执行 error | Generic IN/OUT 与错误摘要 | +| persistent `bash`/`pwsh` running | terminal prompt | +| persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card | +| `terminal_send` 前台 | terminal prompt 与 output | +| `terminal_send` background/error | Generic 结果 | +| Code Dispatch child | 当前 flattened Generic 形态 | + +标准 shell 结果继续解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。已解析的 marker 从正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。 + +调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。 + +同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。 + +TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。 + +### Diff 卡片 + +| 输入 | 保持的结果 | +|---|---| +| running `write` | 从 `file_path` 与 `content` 生成 intended added-only diff | +| running `edit` | 从 `file_path`、`old_string`、`new_string` 生成 intended replacement diff | +| settled `write`/`edit` success | 从 `meta.diffs` 生成 applied contextual hunks | +| write create 或 applied metadata 缺失、畸形、为空 | 当前 args fallback | +| error、畸形 args、edit 的 metadata 畸形、Code Dispatch child | Generic | + +路径、`oldText:null`、`newText`、结果覆盖调用时 diff、Chat 8 行上限、Details 全高显示和文件打开行为不变。 + +### Read 卡片 + +running `read` 继续只有摘要行。成功 settled `read` 从 result meta 读取 path、offset、lines、totalLines 与 lang,并确认结果是单个文本块且符合 read envelope。 + +meta 缺失、字段畸形、result envelope 不匹配、error、缺失 call head 或 Code Dispatch child 都走 Generic。路径 label 的 cwd 相对化、home 缩写、语法语言、总行数、Chat 8 行上限与 Details 全高显示不变。 + +Client 不需要构造 Host `ReadResultView.content`;Generic fallback 始终可直接读取原始 result content。 + +### Search 卡片 + +running `grep`/`glob` 继续只有参数摘要。成功结果分别从 `meta.shape:'matches'` 与 `meta.shape:'paths'` 生成 grouped matches 或 path list。 + +Client 校验 path、lineNumber、line、truncated 与 total。空 matches/paths 是有效卡片;缺失/畸形 meta、未知 shape、error、缺失 call head 与 Code Dispatch child 走 Generic。 + +`truncated:true` 时继续从原始 result content 显示 recovery locator;未截断时不显示。Chat 8 行上限、Details 全高显示和展开行为不变。 + +### Web 卡片 + +running `web_search`/`web_fetch` 继续只有摘要行。成功 search 从 `meta.sources`、`meta.answer`、`meta.truncated` 生成卡片;成功 fetch 从 `meta.url`、`meta.statusCode`、`meta.truncated` 生成卡片。 + +Client 校验每个 source 的 url、title、snippet 与 publishedAt,并继续只把 http/https URL 渲染为链接。meta 缺失或畸形、error、缺失 call head 与 Code Dispatch child 走 Generic。 + +search 的 answer、来源顺序、label fallback 与截断提示不变;fetch 的最终 URL、状态、截断提示与 Details 下方原始正文不变。 + +### 已直接使用 raw block 的 renderer + +- Todo row 继续从 args 计算 completed/active 摘要。 +- Question row 继续从 result content 与 error 计算等待、回答、取消和中止状态。 +- Skill row 继续从 args/result 计算名称与状态。 +- Cordis define/run/action rows 继续从 args/result 与各自 Client service 计算。 +- 这些 renderer 的 props、slot key、注册顺序与可见结果不变。 + +## Deliverables + +`ui-deliverables` 独立于展示意图派生 mutation 业务事实,因此 produced-file 行为不与卡片截图耦合。 + +Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/result`,保存最小的 Client-owned mutation candidate,不扫描 Session window,也不依赖 UI renderer。 + +| 工具 | mutation 判定 | path 来源 | +|---|---|---| +| `write` | 任意成功调用 | `file_path` | +| `edit` | 任意成功调用 | `file_path` | +| `str_replace_editor` | `create`、`str_replace`、`insert` | `path` | +| `str_replace_editor` | `view` | 不产生 path | +| 其他 | 无当前第一方 mutation 语义 | 不产生 path | + +失败、interrupted、orphan result、缺失 path 与畸形 args 不产生 deliverable。同一路径保持 first-seen 去重,closing Assistant seq 之后落定的结果继续排除。 + +本次不新增通用“工具副作用”注册表。Host-only 第三方 presenter 通过 `kind:'edit'`/`locations` 自动加入 Deliverables 的能力被有意移除;未来若有真实第三方 mutation 需求,应由 Client 业务贡献表达,不能恢复 Session view。 + +## Fixture 与测试数据 + +Client fixture 删除手写 `presentCall()`、`presentResult()`、`viewFor()` 与 fixture tool-view 类型。它继续产生与真实日志相同的 raw call、result content 和 result meta。 + +| Fixture | 必须保留的原始事实 | +|---|---| +| terminal | 参数与真实结果 status marker | +| diff | 参数与 result `meta.diffs` | +| read | result meta 的 path/offset/lines/totalLines/lang | +| grep/glob | result meta 的 shape/files 或 paths/truncated/total | +| web | result meta 的 sources/answer 或 url/statusCode/truncated | +| generic/custom | name、argsRaw、content、error | + +fixture 不导入 Host 工具包来计算页面展示,也不保留 presenter 镜像。同一 raw fixture 继续驱动 jsdom、built Web snapshot 与 `?fixture` 浏览器路径。 + +## 展示等价矩阵 + +“当前展示”由已提交的组件测试、组装测试与 Web browser expected 共同定义。transport 或 ownership 重构不能作为 refresh snapshot 的理由;获批产品变化需要独立证据。 + +| 场景 | 必须保持的展示 | +|---|---| +| 未知工具 running | Generic 行,工具名与 args 摘要 | +| 未知工具 settled | Generic 行与原始 output | +| malformed args | 安全 Generic fallback | +| orphan result | callId 标题与 Generic output | +| interrupted call | warning/stopped 状态 | +| bash/pwsh 前台 | 当前 terminal prompt、正文、cwd 与状态 | +| bash/pwsh background/error | 当前 Generic IN/OUT | +| persistent shell | 当前 running terminal、settled Generic | +| terminal_send | 当前前台 terminal、后台/error Generic | +| write/edit | 当前 intended/applied diff 与 error fallback | +| read | 当前 running 摘要、settled ReadBlock 与 error fallback | +| grep/glob | 当前 grouped/path card、截断与 recovery | +| web_search/web_fetch | 当前来源/摘要 card 与原始正文 | +| Todo/Question/Skill/Cordis | 当前专用行 | +| Code Dispatch subcall | 当前 Generic/flattened 形态 | +| Chat 与 Details | 同一调用使用相同 card fields | +| Trajectory | 当前 identity、树、选择和 details | +| Deliverables | 当前成功 mutation chips 与链接 | + +## Client 扩展约定 + +`tool.call.toolview` 继续是唯一工具 UI 注册机制。一个工具若要在 Client 获得专用表现,必须由 Client 插件注册自己的 wire tool name。 + +注册方接收 raw `ToolCallBlock`、Session path 信息和宿主动作,自行校验它认识的 args/meta 字段。注册方不调用 Host tool registry,不依赖 `presentCall`/`presentResult`,也不能要求 `SessionEventEntry.view`。 + +没有 Client renderer 的工具稳定降级为 Generic。同一 tool name 只能有一个生效 keyed registration,重复 key 继续 loud failure。 + +Session-scoped slot 可以表达 Client 侧会话差异,但不从 preset 推断 renderer 变体。Host-only presenter 不自动赋予 Web rich card,这是“Host 描述展示”与“Client 插件拥有展示”的明确边界。 + +## 失败与 fallback + +- Client 把 args 与 meta 当作 wire JSON,在消费点收窄。 +- 参数 JSON 解析失败走 Generic。 +- 已知工具缺少必要字段走 Generic。 +- metadata 缺失或畸形走 Generic;成功 `write` 例外,它按当前 presenter 行为保留由参数派生的整文件 diff。 +- error result 不因 metadata 存在而显示成功卡片。 +- 缺失 call head 不猜测工具名称或参数。 +- 未知 metadata 字段被忽略。 +- 新 metadata variant 在旧 Client 中走 Generic。 +- card-model helper 捕获可预期解析失败,不依赖 React error boundary 完成普通 fallback。 +- keyed renderer 自身的意外异常仍由现有 Slot error isolation 处理。 + +## 同名 Host provider + +Host registry 允许不同 scope 为同一 tool name 提供不同定义;Session view 通过 presenter scope 理论上可以按 preset 选择不同 render intent。删除 view 后,Client keyed slot 只观察 wire name,不能观察 Host definition identity。 + +当前第一方显著实例是普通与 persistent `bash`/`pwsh`。Client 派生使用有效参数与结果特征保持它们的已交付差异,不增加 provider-id wire 字段;无法判别的畸形或自定义同名 provider 输入采用 Generic。 + +本次不承诺保留第三方同名 provider 仅通过 Host presenter 表达的差异。若未来产品确需同名 provider 的不同 Client 展示,必须定义稳定、非展示性的 Client identity;不得恢复按页 Host view 计算。 + +## 已交付范围 + +### Session Controller + +- `SessionEventEntry` 只包含 raw event。 +- 两个 Session tool-view 类型都不存在。 +- history 不含 presentation import、helper 或 page/follow presentation state。 +- 地址、分页、follow 与 projection 逻辑仍由 Session owner 负责。 +- Host 测试固定 raw journal 约定。 + +### Session Controller Client + +- `Session.views` 不存在。 +- EventSource replace/prepend/append delta 保持不变。 +- transport、fixture 与 test-support 类型携带 raw entry。 +- event identity 与引用稳定性保持不变。 + +### UI Conversation、Chat 与 Trajectory + +- Conversation input 与 Tool block 不含 view 字段。 +- Chat/Trajectory Tool Definition 读取 raw event。 +- event pairing、Context replay、树与 target snapshot 保持不变。 +- child Tool block 保留现有 Code Dispatch `parentCallId`;row 与 Details slot owner props 都不增加独立 placement 字段。 + +### UI Tool 与 Deliverables + +- card model 从 raw block/meta 派生。 +- Chat 与 Details 复用相同 helper。 +- Generic fallback 与 keyed dispatch 保持不变。 +- Deliverables 识别第一方 mutation args。 + +### Fixture、文档与生成物 + +- fixture 只发 raw event/meta。 +- Session Controller 与 Client README/JSDoc 描述 raw journal 和 Client presentation owner。 +- 工具 cookbook 记录 Web Client 接入路径。 +- 本文是该决定的 owner;保留的 Host presenter Note 继续拥有各自决定。 +- 手写 Remote 类型、dependency、README、pairing record 与 generated reference 保持同步。 + +## 验证矩阵 + +### Host + +- page 返回连续 raw event entries。 +- follow 返回 opening cursor 与连续 raw event entries。 +- page/follow 在无 Tools service 时行为相同。 +- cold page 不解析或挂载 preset。 +- tail page 通过标准 projection registry 计算 baseline;provider 是否存在由 projection composition 决定,不引入 history 侧 setup 路径。 +- 地址、ownership、message-aligned boundary 与 tail projection 不变。 +- listener-before-read、reconnect catch-up 与 gap repair 不变。 +- 大量 tool results 不触发每结果回扫。 +- wire 结果不含 view。 + +`session-history-journal.host.spec.ts` 负责分页、连续性和 history error 行为,不含 presenter 断言。 + +### Client Conversation + +- replace、prepend 与 append 接受无 view entry。 +- Chat 与 Trajectory root call/result 配对不变。 +- Code Dispatch 树不变。 +- result-only fallback 不变。 +- interruption synthetic result 不复制 view。 +- registry rebuild、older prepend 与 live append 的 Node identity 不变。 + +### Client card model + +- terminal 用 raw args/content 得到已固定的 props。 +- diff 用 args/meta 得到已固定的 diffs。 +- read 用 meta/content 得到已固定的 lines。 +- search 用 meta/content 得到已固定的 grouped/path card 与 recovery。 +- web 用 meta/content 得到已固定的 sources/fetch summary。 +- unknown、malformed、error、missing-call 与 missing-meta 继续 Generic。 +- `parentCallId` 缺失与存在的用例证明结构化展示不会到达 Code Dispatch descendant。 +- Chat 与 Details 对同一 block 得到相同 card fields。 + +### Deliverables + +- write/edit 成功产生 `file_path`。 +- str_replace_editor create/str_replace/insert 产生 `path`。 +- str_replace_editor view 不产生 path。 +- failure、interrupted、malformed 与 orphan 不产生 path。 +- first-seen 去重与 closing seq cut 不变。 + +### 组装与浏览器 + +- terminal、diff、read、search、web browser expected 不刷新并全部通过。 +- tool tree、details、trajectory 与 deliverables 的可见断言不改预期。 +- built Client 通过真实 Remote page/follow 取得 raw events 后仍显示同样卡片。 +- fixture 与真实 Host 使用同一 Client derivation。 +- minimal preset 单独固定 persistent shell 行为。 + +### 静态与文档 + +- 生产代码不存在 `SessionToolView`/`SessionToolCallView`。 +- Session history 不引用 `dsh-tools/presentation`、`ctx.tools`、`presenterScopeFor` 或 `backscanArgs`。 +- Client Conversation 不引用 `ToolCallView`/`ToolResultView`。 +- Client model 不读取 `callView`/`resultView`。 +- fixture 不定义 presenter mirror。 +- Host `presentCall`/`presentResult` 与 `presentationMeta` 仍存在。 +- 没有新增 Client registry 或 Host→Client presentation hint。 +- 受影响的手写类型、README、Agent Note、catalog 与 graph 保持同步。 + +## 验证命令 + +修改本决定时使用 `dsh-pre-push-checks` 按最终 diff 选择命令;所需证据包括: + +- Session Controller history/transport 聚焦测试; +- ui-chat 与 ui-trajectory Tool Definition 测试; +- ui-tool terminal、diff、read、search、web、row、tree 与 details 测试; +- ui-deliverables produced-files 测试; +- connection fixture 与 Client runtime 测试; +- 受影响 Host/Client TypeScript face; +- lint 与 duplication; +- 受影响源文件 per-file 100% coverage; +- `DSH_SNAPSHOT=replay pnpm run test:web`,不得 refresh 现有展示 golden; +- 手写 Remote 类型与 TypeScript 检查; +- `pnpm run doc-sync`; +- `git diff --check`。 + +## 已交付不变量 + +- Session page/follow 不读取 Tools registry 或 presenter scope。 +- Session history 不存在 callId backscan、presentation cache 或 view clone。 +- Remote Session entry 不携带 view。 +- Session 日志与 `SESSION_FORMAT_VERSION` 不变。 +- result meta 逐字节通过日志与 Remote 到达 Client。 +- Conversation 只从 raw event 组装 ToolCallBlock。 +- ToolCallBlock 不含 Host render-intent 字段。 +- 五类结构化 card model 只读 raw block、其现有 `parentCallId` 与 Session path facts。 +- Generic、Todo、Question、Skill 与 Cordis 行行为不变。 +- Deliverables 不依赖 render intent 且保持当前 paths。 +- 所有第一方顶层工具的文本、组件、展开内容、状态、链接与排序不变。 +- malformed、missing-meta、error、orphan 与 unknown-tool 继续安全 fallback。 +- Code Dispatch 子调用保持 Generic/flattened。 +- Chat、Details 与 Trajectory 行为不变。 +- 现有 Web browser expected 无需刷新即可通过。 +- Host presenter API、实现与直接测试不变。 +- ACP 输出不变。 +- 没有新下行展示字段或第二套 Client registry。 +- 分页成本不再随 result 数量乘以页面事件数增长。 +- 下行 payload 不再重复 result meta 的 card DTO。 + +## Alternatives considered + +### 只优化 `backscanArgs`,保留 view + +page 前建立一次 `callId → {name,args}` Map 可以把回扫降为线性,live 已有 `openCalls` 快路径;但 Host lookup、preset scope、presenter、JSON clone、重复 payload 和双重所有权仍存在,因此拒绝。 + +### 在 Client 建 presenter registry + +把 `presentCall`/`presentResult` 接口复制到浏览器会与 `tool.call.toolview` slot 重复注册、生命周期、fallback 和覆盖语义;renderer 仍需把 presenter DTO 转成组件 props,因此拒绝。 + +### 让 Conversation Tool Definition 生成统一 view + +这会把工具名称和 UI card 语义放进 target-neutral Conversation owner,并重建与 Host view 同构的中间 DTO,因此拒绝。 + +### 删除 `presentationMeta` + +read 行结构、applied diff、search 分组、web sources 和有效 truncation 无法从模型文本无损恢复;解析自由文本也会把 UI 绑到输出措辞,因此拒绝。 + +### 持久化 canonical tool result + +这会扩大 Session log、暴露内部结果结构、改变持久格式,并可能保存远超展示所需的大对象;已有 metadata 足够,因此拒绝。 + +### 删除 Host presenter API + +一并删除可以继续收缩代码,但本决定保留 Host `presentCall`/`presentResult`;其 API、实现、测试与类型独立于 Session Remote。 + +### Client 导入 Host 工具实现 + +工具包包含 Node、filesystem、subprocess 或 provider 依赖,不能进入浏览器 bundle;Client 只消费 raw JSON,并在自己的 renderer 内维护窄解析,因此拒绝。 + +### 按结果向 Host 查询 presentation + +按需 RPC 会把一页读取变成 N 次网络调用,仍需 Host lookup、scope、callId 查找与错误协调,因此拒绝。 + +### 允许展示增强 + +Client 可以为 Code Dispatch 子调用、缺失 call head 或 Host presenter 不可用的历史生成更多 rich card,但这会混淆 ownership 变化与产品行为,并使快照无法证明对等,因此拒绝。 + +### 接受临时 Generic 退化 + +先停发 view 再逐步补 Client card 会让 terminal、diff、read、search、web 与 Deliverables 在中间版本退化。Client 对等实现与 Host 删除必须在同一可发布变更中完成。 + +## Consequences + +本决定从 Session 读取中删除 presentation 工作、重复扫描和重复 view payload;代价是保留的 Host presenter 与 Client card derivation 可以独立演进,因此两侧都需要 owner 专属测试,Web 展示对等仍是明确产品约束。 + +### Client 与 Host 逻辑漂移 + +同一工具可以有一份 Host render intent 和一份 Client card derivation。两者面向不同消费方,不共享运行路径;不刷新的 browser expected 固定第一方 Web 视觉对等,Host presenter 测试只约束 Host API。 + +### 同名 provider 无稳定 identity + +raw event 只记录 tool name,不记录具体 ToolDefinition。Client 使用有效事件字段保留普通与 persistent shell 的差异;无法判别的自定义或畸形输入回退 Generic,wire 不为理论扩展性增加 hint。 + +### Metadata 是未知 JSON + +旧 Session 可能缺字段,手工修改日志可能带畸形值。每个 Client model 必须局部收窄,不能把未知数组或对象直接传给 UI primitive。 + +### 双 target 同步 + +Chat 与 Trajectory 各有独立 Tool Definition,两者都携带 raw fields;card derivation 只能留在 `ui-tool`,不能复制进两个 Definition。 + +### Deliverables 隐性依赖 + +Deliverables 不是视觉组件,因此 mutation parser 必须与受支持的第一方写工具保持同步;专用测试独立于卡片截图固定 file chips 与 Markdown links。 + +### Fixture 假绿 + +fixture 下发 raw event/meta,不下发手写 view。真实 Host 组装覆盖仍然必要,因为 fixture-only snapshot 不能证明 transport 路径。 + +### 错误刷新快照 + +本次承诺用户可见输出不变。出现 snapshot diff 时必须修 Client 派生;除非 owner 单独批准具体视觉变化,否则不得 refresh expected。 + +### 文档漂移 + +raw journal 或 Client presentation owner 变化时,Agent Note、package README、cookbook、根规则与 generated reference 必须一起更新;Host API 文档保持独立。 + +### Remote 协议收缩 + +optional `view` 的缺失是所有 consumer 共同遵守的预发布 wire 类型决定;没有兼容 shim、双写或版本协商。 + +## 与现有决策的关系 + +本文部分取代 [Client 工具展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md) 中“card model 接收 Host view”的实现事实;`ui-tool` 拥有展示、业务插件使用 keyed slot、Conversation 只拥有生命周期与拓扑的核心决定保持不变。 + +本文保留 [toolview 溶解](2026-07-23-toolview-dissolution.zh.md) 的决定:Client 仍只有 slot 注册模型,不恢复 `ToolViewRegistry`。 + +本文收窄 [render-intent union](2026-07-02-tool-render-intent-union.zh.md) 的消费范围:Host API 与类型保留,Session Remote 与 Web Client 不消费它。本文独自规定 transport 拆分,不改写该 presenter 决策。 + +本文更新 [Session 历史与 Remote 事件传输](2026-08-18-session-history-and-event-transport.zh.md) 的 entry 约定:journal 只运输原始 event 与独立 projection baseline,不承载临时 tool view。 + +本文遵循 [Conversation Node 组装](2026-08-09-client-conversation-node-assembly.zh.md):Tool Definition 负责事件配对与调用树,具体 card model 留在 `ui-tool`。 + +本文保留 [规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md) 的 result metadata,因为它是无损、可重放 Client 派生的输入。 + +## Deferred + +- Host presenter 若长期没有生产消费者,可由另一项明确决策评估删除;本决定不预判。 +- Code Dispatch 子调用若要专用卡片,需单独设计并更新可见快照;本决定保持现状。 +- 第三方 mutation tool 若要加入 Deliverables,需新增 Client-owned 贡献;本决定不为尚无消费者的扩展性建 registry。 +- 同名 provider 若要不同 Client 展示,需先定义稳定、非展示性的 identity;不得恢复按页 Host view。 +- Client card model 若需量化性能,可以增加 immutable-block 微基准;已交付架构禁止扫描 Session window。 diff --git a/AGENTS.md b/AGENTS.md index 1928b25c31..3fa1ce77e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless runnable-example snapshot; package, e2e-only, and mock-only tests do not substitute. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. -- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). +- **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. - **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)). - **Choose PR history deliberately.** Split independent changes and fix the introducing PR before propagation. Standalone/stack branches may merge-forward or rebase. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; preserve an in-progress merge-forward checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index f11b12b5ba..18ce4bd3df 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: 9685f4689a6ebd6994c1fc6dccbae04af1658a15 -config-catalog.zh.md: 51575325b3656487e2fe852a6c8cf0b47b2b6fe8 +config-catalog.md: 8d03e3757388d4f2d317afb4c06537905a9f3688 +config-catalog.zh.md: 81dce53256071fdb3b0059552bca85569c53a3db diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9685f4689a..8d03e37573 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -269,7 +269,7 @@ Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/co ## `@deepseek-ai/dsh-api-session-controller` -Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ @@ -2990,7 +2990,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 51575325b3..81dce53256 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -271,7 +271,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `workspaceRegistry` +需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ @@ -2992,7 +2992,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -来源:[`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 5920054e1f..bf2d9d4d3f 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-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 docs/cookbook/adding-a-tool.md -adding-a-tool.md: 37516521de4d00de964003fd6f877831774fdcd3 -adding-a-tool.zh.md: 6a24d5dc303990f9fe13e77c9a92b3a24ca16647 +adding-a-tool.md: 4e07c33dd372ae95391fcad5236832a6f7662e82 +adding-a-tool.zh.md: 17a024a0db0d63ec9ef8c9407e77a471263427c9 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 37516521de..4e07c33dd3 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -87,7 +87,13 @@ Hard rules (they bite if broken): - **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve a UI. `output.render` owns model-facing prose; `presentationMeta` plus the card presenters own replayable UI state. A `terminal` result view carries raw output and the adapter adds any fallback framing. - **`defineTool` soft-validates the display path.** Malformed or older logged arguments make the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. -The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Host/client runtimes map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. +The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Consumers of this API map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. + +## Web Client presentation + +The built-in Web Client does not consume `presentCall` or `presentResult`. Session `page` and `follow` transport raw `tool/call` and `tool/result` events, including persisted `result.meta`. A Client plugin registers its wire tool name in the `tool.call.toolview` keyed slot and derives component props from the `ToolCallBlock` arguments, content, error, metadata, existing Code Dispatch `parentCallId`, and Session path facts. It validates these wire values locally and returns the generic row for malformed or unsupported input. + +Use `output.presentationMeta(args, value)` when an existing Web card needs bounded structured result facts that model-facing content cannot preserve losslessly. Do not store React props or a selected card in metadata, import a Host tool implementation into a browser bundle, or create another Client presenter registry. Defining Host presentation methods alone does not add a specialized Web card. The [Client-derived presentation Agent Note](../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md) defines ownership, fallback, and equivalence requirements. ## Verification diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 6a24d5dc30..17a024a0db 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -89,7 +89,13 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径均不应仅为服务 UI 而进入规范值或 Native 内容。`output.render` 负责模型可见的自然语言;`presentationMeta` 和卡片展示器负责可回放的 UI 状态。`terminal` 结果视图携带原始输出,由适配器按需添加回退格式。 - **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的参数会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 -中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 +中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。使用该 API 的消费方把每个 `card` 映射到自己的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 + +## Web Client 展示 + +内置 Web Client 不消费 `presentCall` 或 `presentResult`。Session `page` 与 `follow` 运输原始 `tool/call` 和 `tool/result` 事件,包括持久化的 `result.meta`。Client 插件在 keyed slot `tool.call.toolview` 中注册自己的 wire 工具名称,并从 `ToolCallBlock` 的参数、内容、错误、metadata、现有 Code Dispatch `parentCallId` 与 Session 路径事实派生组件 props。插件在本地校验这些 wire 值,并让格式错误或不受支持的输入回退到 generic 行。 + +现有 Web 卡片需要模型可见内容无法无损保存的有界结构化结果事实时,使用 `output.presentationMeta(args, value)`。不要在 metadata 中保存 React props 或预选卡片,不要把 Host 工具实现导入浏览器 bundle,也不要建立另一套 Client presenter registry。只定义 Host 展示方法不会增加专用 Web 卡片。[Client 派生展示 Agent Note](../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md)规定 owner、fallback 与对等要求。 ## 验证 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 32599e0510..c9e637910e 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: 8cf11d8be322686c89f8bc57c9f0bc2c4a3aeb74 -event-producer-consumer.zh.md: 6b79a0fded3b5fa6956e9d7b047e78b68ded1f48 +event-producer-consumer.md: 2be5a84969b9f14823abf90cf289a0a41e48dd11 +event-producer-consumer.zh.md: 5bbae1be5d03c3e443d36093ce60dbf7e4b07971 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8cf11d8be3..2be5a84969 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:442`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:448`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:455`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6b79a0fded..5bbae1be5d 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:442`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:448`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:455`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 90fea5010f..b8a9b43bd1 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: 0d26be0a33b6aeca80fd04d9fdabc8f977a714df -module-graph.zh.md: 396d5879e26c8da1446d83323991de13acf0bd61 +module-graph.md: d70aa9a7704a7de5b669928a6cafd8358fb2a3b0 +module-graph.zh.md: 2333d71e61bd935fa482fc766bb7d96bb75d56db diff --git a/docs/module-graph.md b/docs/module-graph.md index 0d26be0a33..d70aa9a770 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1083,7 +1083,6 @@ flowchart TD pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session pkg_client_connection --> pkg_tool_todo - pkg_client_connection --> pkg_tools pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1255,7 +1254,6 @@ flowchart TD pkg_api_session_controller --> pkg_session_query pkg_api_session_controller --> pkg_session_title pkg_api_session_controller --> pkg_subagent - pkg_api_session_controller --> pkg_tools pkg_api_session_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_typert_registry pkg_api_session_controller --> pkg_util_workspace_path @@ -1820,7 +1818,7 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1842,7 +1840,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 396d5879e2..2333d71e61 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1085,7 +1085,6 @@ flowchart TD pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session pkg_client_connection --> pkg_tool_todo - pkg_client_connection --> pkg_tools pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1257,7 +1256,6 @@ flowchart TD pkg_api_session_controller --> pkg_session_query pkg_api_session_controller --> pkg_session_title pkg_api_session_controller --> pkg_subagent - pkg_api_session_controller --> pkg_tools pkg_api_session_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_typert_registry pkg_api_session_controller --> pkg_util_workspace_path @@ -1822,7 +1820,7 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1844,7 +1842,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 0afa4d58c5..339baa81cc 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: edb8f4ebb427bfce6e4def023e65f4697608ceb2 -session.zh.md: 7b3c7a8e50688ba19694d5f45e43d224c2245ef1 +session.md: b7806a4989684be7585d8d42ac215fe1ab1540f0 +session.zh.md: a80a3146b50c4c0fdcf4c3e54e1dc4943eb28642 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index edb8f4ebb4..b7806a4989 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -696,7 +696,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH /** * Read one cold-safe, message-aligned Session history page. * @param request - durable address, backward cursor, and page budget. - * @param signal - cancellation for persistence and presentation reads. + * @param signal - cancellation for persistence reads. * @returns one chronological page and optional latest projections. */ @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7b3c7a8e50..a80a3146b5 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -700,7 +700,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH /** * Read one cold-safe, message-aligned Session history page. * @param request - durable address, backward cursor, and page budget. - * @param signal - cancellation for persistence and presentation reads. + * @param signal - cancellation for persistence reads. * @returns one chronological page and optional latest projections. */ @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 51a624d98a..31c76de3e1 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: cda9349e432472a0ed9fd623afef0b689ff72f73 -README.zh.md: 2aaee8f968cf7373110e291c197adbeca21f490e +README.md: 7631e1623f90f9349eca78bc76d46505d13d2e0e +README.zh.md: 7a733b45b1cdbb17096d1e76bb25b54d3bdc0e06 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index cda9349e43..7631e1623f 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) `@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state. +History pages and follow event frames carry only raw `SessionWireEvent` values. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. + Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 2aaee8f968..7a733b45b1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -4,6 +4,8 @@ `@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。 +历史页与 follow event frame 只携带原始 `SessionWireEvent`。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 + 每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 2165e19c2a..b45dc60a48 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -52,7 +52,7 @@ Non-negotiables across the layers: - **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer. - **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest

`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). - **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`. -- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule). +- **The web layer is pure presentation.** Nothing that is only "how to draw" enters the session log. Tool cards derive in the Client from raw call/result events and persisted result metadata; process-local control state uses its own snapshots and frames. Unknown or malformed tool data falls back to the generic form. A new *model-visible* input still requires a session event (repo-wide rule). ## Dependency declaration diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ffb66bc995..a6102ddac0 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 6f47cd87af46cc270d3160482ad047b249aa5053 -README.zh.md: 38e3c626073e9e90a16eebdb91af1e89d4da7c22 +README.md: 4c9665b680fe1922770403d88a04dffc755481ad +README.zh.md: 8bf3db2cb1401427f29016f8dbddcd9d27ec9635 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 6f47cd87af..4c9665b680 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. -The adapter converts each `SessionEventEntry` to `ConversationEventInput` as `{ event, view? }`: the raw Session event is preserved and the envelope-level tool view is included only when present. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. +The adapter converts each `SessionEventEntry` to a `{ event }` `ConversationEventInput` and preserves the raw Session event, including tool-result metadata. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 38e3c62607..8bf3db2cb1 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,7 +8,7 @@ `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。 -adapter 将每个 `SessionEventEntry` 转换成 `{ event, view? }` 形式的 `ConversationEventInput`:原始 Session event 保持不变,仅在 envelope-level tool view 存在时携带 `view`。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 +adapter 将每个 `SessionEventEntry` 转换成 `{ event }` 形式的 `ConversationEventInput`,并保留原始 Session event,包括工具结果 metadata。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index c131cc33d9..77799f1afb 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: e7118eac75f31b3ffc3f2434371dba06f029d241 -README.zh.md: 62aa568bb7a14b5f703e60a0c263e851d01734ed +README.md: ace08fae3a001080918973c23aa362080cd69066 +README.zh.md: 9df1664bc42c39012c2eebf397cb368b3b5c6260 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index e7118eac75..ace08fae3a 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Produced-files and clickable-reference feature owner. The Node half registers final-response guidance with the system-prompt registry; the browser half registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole and links matching inline-code references in the closing prose. The shipped Web patch is the only composition that loads this package. Removing its one cordis.yml entry removes the guidance, row, and prose links together. -`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row. +`deliverablesDefinition` folds each Turn's successful first-party mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The source is the validated raw arguments of `write`, `edit`, and the mutating `str_replace_editor` commands (`create`, `str_replace`, and `insert`), never presentation data or closing prose: a produced file is listed whether or not the model remembered to name it. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing; a path appears once per Turn in first-seen order. A new mutation tool needs an explicit Client contribution before it joins this list. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row. `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label and one measured file lane. It shows the largest leading prefix that fits (up to six chips; basename text, full path as the `title`) while reserving the exact localized `+ N files` width, so the remainder stays visible without wrapping or horizontal scrolling. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. When files are hidden, a second-line **Show in folder** action opens the session workspace through that same owner path only while the page is loopback and the current Host handshake reports `canOpenPath`; direct remote Web and headless/container Linux Hosts omit the action by default. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 62aa568bb7..9df1664bc4 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -4,7 +4,7 @@ 产出文件与可点击文件引用功能的属主。Node 侧向系统提示词 registry 注册最终回复指引;浏览器侧把已完成轮次末尾的产出文件行注册到 chat 视图的 `conversation.chat.turnTail` slot,并将收尾正文中匹配的行内代码引用转换为链接。正式提供的组合中只有 Web patch 加载本包;从 cordis.yml 中删去这一项会同时移除提示词、文件行与正文链接。 -`deliverablesDefinition` 把每个轮次中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个轮次内按首见顺序只出现一次。Conversation Location 索引负责维护轮次归属关系,因此一个轮次即使先修改文件、随后没有正文内容就结束,也不会溢进下一个轮次的行里。 +`deliverablesDefinition` 把每个轮次中成功的第一方修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是 `write`、`edit` 和 `str_replace_editor` 修改命令(`create`、`str_replace`、`insert`)经过校验的原始参数,而不是展示数据或收尾正文:无论模型是否记得点名,产出文件都会被列出。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目;同一路径在一个轮次内按首见顺序只出现一次。新的修改工具必须先增加显式 Client contribution,才能加入该列表。Conversation Location 索引负责维护轮次归属关系,因此一个轮次即使先修改文件、随后没有正文内容就结束,也不会溢进下一个轮次的行里。 `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签和一条经过测量的单行文件 lane。它展示能够放下的最大前缀(至多六个标签项;文本为文件名,完整路径作为 `title`),并为本地化后的精确 `+ N 个文件` 宽度预留空间,因此剩余计数始终可见,既不换行也不横向滚动。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。存在隐藏文件时,第二行的**在文件夹中显示**也经由同一属主路径打开会话 workspace;它只在页面使用 loopback 且当前 Host 握手报告 `canOpenPath` 时出现,直接远程 Web 与 headless/容器 Linux Host 默认均省略该操作。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)。 diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index f31167fbde..a9bd41853a 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 79b1bf27d848f015f132e38a635dd98c05a5dd87 -README.zh.md: 89469445b346dcb58a192a51cacdf9e66af50647 +README.md: 16fd06332d24b265ac7d6b7b000870686262b9c5 +README.zh.md: a21acab8ed6e7b52770a9ee6a63219c11b949c3d diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 79b1bf27d8..16fd06332d 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -12,7 +12,7 @@ Business UI packages register only their wire Tool names and atomic views. They Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection. -The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text. +The package also fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, the existing Code Dispatch `parentCallId`, and Session path facts; unsupported or malformed inputs fall back to flattened Tool result text. Generic rows classify known Tool names into search, read, shell, write, edit, code, or generic variants. Running, successful, failed, and interrupted lifecycle states come only from the frozen call/result slice. File paths resolve against the session `cwd` only when the user invokes the Host open-file callback; presentation code does not read Session services. @@ -28,9 +28,9 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. Path summaries relativize to the session cwd first, then replace a leftover POSIX host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge. +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; the field is absent on a root Session call, so row and Details card models preserve the generic flattened form for descendants without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service. -This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`. +This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 89469445b3..a21acab8ed 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -12,7 +12,7 @@ Client 工具展示插件。`ui-conversation` 通过 `conversation.chat.node` 每个 root 和 child 包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。 -本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 共用同一组面向 `terminal`、`read`、`diff`、`search` 和 `web` render intent 的纯 card model。未知的 intent 标签和格式错误的 wire card 数据都会回退为压平的工具结果文本。 +本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、现有 Code Dispatch `parentCallId` 与 Session 路径事实;不受支持或格式错误的输入回退为压平的工具结果文本。 通用行把已知工具名称归类为 search、read、shell、write、edit、code 或 generic 变体。运行中、成功、失败和中断状态只来自冻结的 call/result slice。只有用户调用 Host 打开文件回调时,文件路径才相对会话 `cwd` 解析;展示代码不读取会话服务。 @@ -28,9 +28,9 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX 宿主家目录写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。 +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。Code Dispatch block 保留其事件已有的 `parentCallId`;root Session call 没有该字段,因此 row 与 Details card model 无需另一项 placement 标志即可让 descendant 保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。 -本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 和 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) Agent Note 负责。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index dfeb0e2eb3..571012276f 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/tools/README.md -README.md: 4f67dee611e6f28547ab276bd0884e787aeae0d9 -README.zh.md: 0137f295f2810930212dcb3f45e90af73bcf449c +README.md: a140255a04187d4f2206df0f90c950d7608ee8ac +README.zh.md: f519ba0a364f05ab63967c07ffd6681a5f62a70a diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 4f67dee611..a140255a04 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -20,7 +20,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber. - `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). -- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. +- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent). A Host-local presenter consumer passes the calling agent when it must match the definition that executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. - `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only `signal`; the registry re-fuses the original caller signal immediately before the body. @@ -49,7 +49,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../interaction/user-approval/README.md) when mounted and otherwise degrades to deny. - `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. -- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). +- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged values returned by the retained Host-side `presentCall` / `presentResult` extension (see "Host presentation descriptors"). The built-in Web Client does not consume these values. ### Extension points @@ -104,14 +104,14 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E `JsonSchemaNode` is the raw counterpart shared by tool outputs, Code Mode generation, subagents, and workflows. It permits any JSON root, an annotation-only unconstrained JSON node, and exact-one `oneOf`; annotations must remain lossless JSON. `assertSupportedJsonSchema()` rejects unsupported constructs, while `validateJsonSchemaValue()` returns path-qualified violations. Subagents and workflows retain their caller-defined object-root requirement through `assertObjectJsonSchema()` and `ObjectJsonSchema`, not through a limitation in the shared vocabulary. -### Tool-owned UI presentation +### Host presentation descriptors -Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: +Tools may retain pure `presentCall()` and `presentResult()` render intents for Host-local consumers: - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. - Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content). -Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct top-level calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. +Returning `undefined` selects generic fallback for a consumer that invokes the presenter. Presenters depend only on their arguments and the durable result so a consumer can use them during live streaming or log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct top-level calls; that metadata persists with `tool/result` and is available both to `presentResult` and to clients that derive their own presentation, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and returns `undefined` on mismatch. Session Remote does not invoke or transport these presenters: the built-in Web Client selects its renderer through `tool.call.toolview` and derives card props from raw call arguments, result content, failure state, and persisted metadata. `dsh-tool-bash` and `dsh-tool-fs` remain reference presenter implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split, the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the retained card vocabulary, and the [Client-derived presentation decision](../../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md) owns the Web transport split. ### Code Mode diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 0137f295f2..f519ba0a36 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -20,7 +20,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时纳入快照;在所有流水线结果(包括实体化其他结果字段时发现的错误)规范化之后,它只能替换最终面向模型的内容。该注册会随调用方 fiber 一同 dispose(资源释放)。 - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。工具目录保持不变:`schemas(agent)` 仍会报告该 agent 的能力;只有组装结果中的工具列表会按所选呈现方式收束。随调用方 fiber dispose。 - `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md#security-and-authority-are-non-goals)。 -- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:返回指定作用域可见的解析结果,其中已应用名称遮蔽;被作用域限制排除的全局工具会被视为不存在。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 +- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:返回指定作用域可见的解析结果,其中已应用名称遮蔽;被作用域限制排除的全局工具会被视为不存在。需要匹配实际执行 definition 的 Host 本地 presenter 消费方会传入发起调用的 agent。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.zh.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md))。 - `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。 - `ctx.tools.execute(exec)`:以无损方式快照并冻结参数,分配不透明 token,运行完整的策略/分发/结果流水线,然后在最终观测前独立快照权威结果。无效参数会进入同一结果路径,但不会到达策略或工具主体。环绕包装层只能替换 `signal`;注册表会在进入工具主体之前,立即将调用方的原始信号重新合并到当前信号中。 @@ -49,7 +49,7 @@ tools: - `PreToolDecision`:`{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../interaction/user-approval/README.zh.md) 时由它处理,否则退化为拒绝。 - `PostToolDecision`:接受决定可以替换 `content` 或 `value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。 - `ToolGuard`:`(execution) => string | undefined`;返回的字符串是最终单调拒绝理由,在可重排的前置执行 waterfall 之后、分发之前求值。 -- `ToolCallView` / `ToolResultView`:提供方无关、带 `card` 标签的呈现意图;工具通过 `presentCall` / `presentResult` 返回该意图,从而拥有 UI 呈现其自身调用的方式(参见「工具拥有的 UI 呈现」)。 +- `ToolCallView` / `ToolResultView`:保留的 Host 侧 `presentCall` / `presentResult` 扩展所返回的、提供方无关且带 `card` 标签的值(参见「Host 展示描述」)。内置 Web Client 不消费这些值。 @@ -106,14 +106,14 @@ ctx.tools.register(defineTool({ `JsonSchemaNode` 是工具输出、Code Mode 生成、subagent 和工作流共享的原始 JSON Schema 对应类型。它允许任意 JSON 根、仅含注解且不施加约束的 JSON 节点,以及恰好匹配一个分支的 `oneOf`;注解必须保持为无损 JSON。`assertSupportedJsonSchema()` 拒绝不受支持的构造,而 `validateJsonSchemaValue()` 返回带路径的违规信息。subagent 和工作流通过 `assertObjectJsonSchema()` 与 `ObjectJsonSchema` 保留调用方定义的对象根要求,而不是依赖共享词汇的限制。 -### 由工具定义的 UI 呈现 +### Host 展示描述 -工具可以选择通过纯函数 `presentCall()` 和 `presentResult()` 定义呈现意图,使 UI 无需针对工具名称编写特殊逻辑: +工具可以为 Host 本地消费方保留纯函数 `presentCall()` 和 `presentResult()` 呈现意图: - 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。 - 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是去除读取结果外层封装后的正文,供不支持读取视图的 UI 回退显示)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。 -返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接的顶层调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md) 规定卡片词汇。 +返回 `undefined` 会让调用 presenter 的消费方选择通用回退。presenter 只依赖其参数和持久结果,因此消费方可以在实时流式输出或日志回放期间使用。`output.presentationMeta(args, value)` 为直接顶层调用派生 JSON metadata;该 metadata 随 `tool/result` 持久化,既可供 `presentResult` 使用,也可供自行派生展示的 Client 使用,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算 metadata。`defineTool` 会软验证旧日志参数,并在不匹配时返回 `undefined`。Session Remote 不调用也不运输这些 presenter:内置 Web Client 通过 `tool.call.toolview` 选择 renderer,并从原始调用参数、结果内容、失败状态和持久 metadata 派生 card props。`dsh-tool-bash` 与 `dsh-tool-fs` 仍是 presenter 参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md) 规定保留的卡片词汇,[Client 派生展示决定](../../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md) 规定 Web transport 的拆分。 ### Code Mode diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index e8558ee64d..3901480cb0 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -294,9 +294,10 @@ export interface ToolResult { /** Whether the call failed. */ isError: boolean /** - * The tool-private presentation payload projected by its output declaration - * and threaded verbatim from the `tool/result` event. Absent when the tool - * declared no projector or the call was nested under a composite transport. + * The tool-private presentation payload projected by its output declaration. + * It is persisted verbatim on `tool/result` for Host presenters and Client + * renderers to narrow independently. Absent when the tool declared no + * projector or the call was nested under a composite transport. */ meta?: JsonValue } diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index fce084cf6a..0458ac1704 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -647,7 +647,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventEntry', - declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n readonly view?: SessionToolView;\n}', + declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n}', }, { name: 'SessionEventSource', @@ -685,14 +685,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionStandardProps', declaration: 'export interface SessionStandardProps {\n}', }, - { - name: 'SessionToolCallView', - declaration: 'export type SessionToolCallView = (Omit & {\n readonly rawInput?: JsonValue;\n}) | TerminalCallView | DiffCallView;', - }, - { - name: 'SessionToolView', - declaration: 'export type SessionToolView = {\n readonly for: \'call\';\n readonly view: SessionToolCallView;\n} | {\n readonly for: \'result\';\n readonly view: ToolResultView;\n};', - }, { name: 'SessionWireEvent', declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SurfaceOp;\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9d0fc17eb8..6f7d362aa8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1275,7 +1275,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise', description: 'Read one cold-safe, message-aligned Session history page.', - parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence and presentation reads.' }], + parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence reads.' }], returns: 'one chronological page and optional latest projections.', }, { @@ -4415,7 +4415,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventEntry', - declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n readonly view?: SessionToolView;\n}', + declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n}', }, { name: 'SessionEventMap', @@ -4753,14 +4753,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleUserMessage', declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}', }, - { - name: 'SessionToolCallView', - declaration: 'export type SessionToolCallView = (Omit & {\n readonly rawInput?: JsonValue;\n}) | TerminalCallView | DiffCallView;', - }, - { - name: 'SessionToolView', - declaration: 'export type SessionToolView = {\n readonly for: \'call\';\n readonly view: SessionToolCallView;\n} | {\n readonly for: \'result\';\n readonly view: ToolResultView;\n};', - }, { name: 'SessionUpdateQueueRequest', declaration: 'export interface SessionUpdateQueueRequest {\n readonly sessionId: SessionId;\n readonly itemId: MessageId;\n readonly action: QueueAction;\n}', From a42c0b523ae558ebe51131717d184363a9c82bc6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:11:39 +0800 Subject: [PATCH 009/138] refactor(session): stream raw tool events --- apps/web/tests/seeded-history.e2e.ts | 12 +- packages/api/remotes/src/client/index.ts | 2 +- packages/api/session-controller/package.json | 5 +- .../src/client/sessions/session.ts | 8 - .../api/session-controller/src/history.ts | 149 +------ packages/api/session-controller/src/index.ts | 3 +- packages/api/session-controller/src/types.ts | 20 +- .../tests/controller.host.spec.ts | 5 + .../tests/event-script.client.ts | 2 +- ...s => session-history-journal.host.spec.ts} | 232 +++------- .../tests/session.client.spec.ts | 42 +- .../tests/transport.host.spec.ts | 127 ------ .../session-controller/tsconfig.client.json | 1 - .../api/session-controller/tsconfig.host.json | 1 - packages/client/connection/package.json | 6 +- packages/client/connection/src/client/api.ts | 1 - .../client/connection/src/client/fixture.ts | 400 +++++++----------- .../client/connection/src/client/index.ts | 1 - .../connection/tests/fixture.client.spec.ts | 44 +- pnpm-lock.yaml | 6 - 20 files changed, 300 insertions(+), 767 deletions(-) rename packages/api/session-controller/tests/{session-history-view.host.spec.ts => session-history-journal.host.spec.ts} (51%) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 2b2d194657..de689175d9 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -258,13 +258,11 @@ describe('web e2e: seeded history renders through cold resume', () => { // The seed carries a session/title event: the title unit is host-plane, so // it folds the detached log and serves the value with nothing composed. expect(typeof projections?.values.title).toBe('string') - // `todos` IS here, as its empty fold (null). Its unit is registered by - // `tool-todo` inside the default preset's STANDING mount, which the read - // itself ensures — deterministically, not because some unrelated session - // happens to be composed. A present-but-null key is what keeps the - // client's "omitted key = capability absent → clear the row" rule from - // wiping preset-owned projections on cold reads. - expect(projections?.values).toHaveProperty('todos', null) + // `todos` is absent because its unit belongs to the agent preset and this + // directly seeded session never composed that preset. History computes + // the baseline through the standard projection registry without mounting + // an Agent composition as a read side effect. + expect(projections?.values).not.toHaveProperty('todos') // The session-stats unit is a shipped web-app bundle row: whole-log // turn/step counts ride the same tail block (the stats strip's source). const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index db78433d82..528d78dc89 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -52,7 +52,7 @@ export type { MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, - SubagentAddress, SubagentCatalog, ToolCallView, ToolResultView, + SubagentAddress, SubagentCatalog, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 4079dd712a..4fb6a87747 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -96,7 +96,6 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", @@ -106,8 +105,7 @@ "@deepseek-ai/dsh-jobs": { "optional": true }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, "@deepseek-ai/dsh-session-projection": { "optional": true }, - "@deepseek-ai/dsh-session-projection-cache": { "optional": true }, - "@deepseek-ai/dsh-tools": { "optional": true } + "@deepseek-ai/dsh-session-projection-cache": { "optional": true } }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -131,7 +129,6 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index e894517e59..d51d475474 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -22,7 +22,6 @@ import type { SessionQueuedItem, SessionRequestId, SessionError, - SessionToolView, } from '../../types.ts' import type { ClientFailure, ClientResult } from '../contract/result.ts' import { transportResult } from '../contract/result.ts' @@ -74,9 +73,6 @@ export interface SessionOptions { export class Session implements SessionFace { // ---- Window and derived state (all private; the snapshot is the only read API) ---- private eventWindow: SessionEvent[] = [] - /** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view). - * Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */ - private views: (SessionToolView | undefined)[] = [] private baseSeq = 0 private hasMore = false private openState: OpenState = 'cold' @@ -405,7 +401,6 @@ export class Session implements SessionFace { this.openState = 'cold' this.openError = null this.eventWindow = [] - this.views = [] this.baseSeq = 0 this.notifier.markDirty() await this.open() @@ -582,7 +577,6 @@ export class Session implements SessionFace { /** Replace the complete contiguous window and apply page-owned projection metadata. */ private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { this.eventWindow = entries.map(entry => entry.event as SessionEvent) - this.views = entries.map(entry => entry.view) this.baseSeq = this.eventWindow[0]?.seq ?? 0 this.hasMore = hasMore if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false @@ -594,7 +588,6 @@ export class Session implements SessionFace { /** Prepend one stream-validated history page. */ private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void { this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow] - this.views = [...entries.map(entry => entry.view), ...this.views] this.baseSeq = this.eventWindow[0]?.seq ?? 0 this.hasMore = hasMore this.eventSource.prepend(entries, hasMore) @@ -604,7 +597,6 @@ export class Session implements SessionFace { private appendLive(entry: SessionEventEntry): boolean { const event = entry.event as SessionEvent this.eventWindow.push(event) - this.views.push(entry.view) const awaitingFirstTurn = this.firstPromptPendingTurn if (event.type === 'turn/start') this.firstPromptPendingTurn = false const queueChanged = this.queueMirror.acceptDurable(event) diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index ab7bce2525..e30b4f1139 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -1,14 +1,10 @@ /** Cold Session history pagination and live-event source. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' -import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { SessionAddress, @@ -19,34 +15,21 @@ import type { SessionPageRequest, SessionProjectionsBlock, SessionProjectionValues, - SessionToolCallView, - SessionToolView, SessionWireEvent, } from './types.ts' const DEFAULT_MAX_MESSAGES = 50 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -interface ToolCallData { - readonly callId: string - readonly name: string - readonly arguments: string -} - type SessionSource = | { readonly kind: 'attached'; readonly session: Session } | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] } -interface BufferedEvent { - readonly session: Session - readonly event: SessionEvent -} - /** Implements cold-safe history operations delegated by the Session Controller. */ export class SessionHistoryController { private readonly closeFollowers = new Set<() => void>() - /** @param ctx - Host context carrying Session, persistence, presenter, and projection services. */ + /** @param ctx - Host context carrying Session, persistence, and projection services. */ constructor(private readonly ctx: Context) { ctx.effect(() => () => { for (const close of this.closeFollowers) close() @@ -57,7 +40,7 @@ export class SessionHistoryController { /** * Read one message-aligned history page without activating an Agent. * @param request - durable address and backwards-page cursor. - * @param signal - caller cancellation for persistence and preset reads. + * @param signal - caller cancellation for persistence reads. * @returns a contiguous event page and a projection baseline on tail reads. */ async page(request: SessionPageRequest, signal: AbortSignal): Promise { @@ -77,11 +60,8 @@ export class SessionHistoryController { if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) { reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) } - const scope = await this.presenterScopeFor(addressId(request.address), source, events) - signal.throwIfAborted() const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES) - const argsFor = (callId: string) => backscanArgs(page.events, callId) - const entries = page.events.map(event => entryFor(this.ctx, event, argsFor, scope)) + const entries = page.events.map(entryFor) const projections = request.beforeSeq === undefined ? this.projectionsFor(request.address, source, events) : undefined @@ -102,12 +82,7 @@ export class SessionHistoryController { validateFollowRequest(request) const { address, afterSeq } = request const target = addressId(address) - const buffered: BufferedEvent[] = [] - const openCalls = new Map() - let fallbackEvents: readonly SessionEvent[] = [] - const argsFor = (callId: string): { readonly name: string; readonly args: unknown } | undefined => ( - openCalls.get(callId) ?? backscanArgs(fallbackEvents, callId) - ) + const buffered: SessionEvent[] = [] let wake: (() => void) | undefined const notify = (): void => { const resume = wake @@ -122,7 +97,7 @@ export class SessionHistoryController { this.closeFollowers.add(close) const disposeEvent = this.ctx.on('session/event', (session, event) => { if (session.id !== target) return - buffered.push({ session, event }) + buffered.push(event) notify() }, { global: true }) const disposeCreated = this.ctx.on('session/created', (session) => { @@ -130,7 +105,7 @@ export class SessionHistoryController { // Session construction appends session/end-seed before attachment, so the // marker has no session/event notification. Earlier session/created listeners // may publish later setup events first; this suffix must precede those notifications. - const suffix = session.events.slice(session.firstLiveSeq).map(event => ({ session, event })) + const suffix = session.events.slice(session.firstLiveSeq) buffered.unshift(...suffix) notify() }, { global: true }) @@ -139,8 +114,6 @@ export class SessionHistoryController { try { const source = await this.sourceFor(address, signal) const events = [...sourceEvents(source)] - fallbackEvents = events - const scope = await this.presenterScopeFor(target, source, events) signal.throwIfAborted() const cursor = events.at(-1)?.seq ?? -1 if (afterSeq !== undefined && afterSeq > cursor) { @@ -155,7 +128,7 @@ export class SessionHistoryController { reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {}) } nextSeq++ - yield { type: 'event', ...entryFor(this.ctx, event, argsFor, scope) } + yield { type: 'event', ...entryFor(event) } } } while (!follower.closed && !signal.aborted) { @@ -164,26 +137,12 @@ export class SessionHistoryController { await new Promise((resolve) => { wake = resolve }) continue } - if (item.event.seq < nextSeq) continue - if (item.event.seq !== nextSeq) { + if (item.seq < nextSeq) continue + if (item.seq !== nextSeq) { reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {}) } nextSeq++ - if (item.event.type === 'tool/call') { - const data = item.event.data as ToolCallData - const call = parseToolCall(data) - /* v8 ignore next -- malformed durable tool arguments intentionally skip the live presentation cache. */ - if (call !== undefined) openCalls.set(data.callId, call) - } else if (item.event.type === 'turn/end') { - openCalls.clear() - } - if (item.event.type === 'tool/result' - && !openCalls.has(item.event.data.message.source.callId)) { - fallbackEvents = item.session.events - } - const liveScope: Agent | undefined = this.ctx.get('agents')?.get(target) - const entry = entryFor(this.ctx, item.event, argsFor, liveScope ?? scope) - yield { type: 'event', ...entry } + yield { type: 'event', ...entryFor(item) } } } finally { this.closeFollowers.delete(close) @@ -214,25 +173,6 @@ export class SessionHistoryController { return { kind: 'detached', header: inspected.meta, events: inspected.events } } - private async presenterScopeFor( - sessionId: SessionId, - source: SessionSource, - events: readonly SessionEvent[], - ): Promise { - const live = this.ctx.get('agents')?.get(sessionId) - if (live !== undefined) return live - const presets = this.ctx.get('agentPresets') - if (presets === undefined) return undefined - const session = source.kind === 'attached' - ? { header: source.session.header, events } - : { header: source.header, events } - try { - return await presets.standingKeyFor(resolveSessionPreset(session)) - } catch { - return undefined - } - } - private projectionsFor( address: SessionAddress, source: SessionSource, @@ -368,76 +308,9 @@ function paginate( return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 } } -function entryFor( - ctx: Context, - event: SessionEvent, - argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined, - scope?: ScopeKey, -): SessionEventEntry { - const view = viewFor(ctx, event, argsFor, scope) +function entryFor(event: SessionEvent): SessionEventEntry { return { // Session.append validates and freezes event data as JSON before publication. event: event as unknown as SessionWireEvent, - ...(view === undefined ? {} : { view }), } } - -function viewFor( - ctx: Context, - event: SessionEvent, - argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined, - scope?: ScopeKey, -): SessionToolView | undefined { - if (event.type !== 'tool/call' && event.type !== 'tool/result') return undefined - const tools = ctx.get('tools') - /* v8 ignore next -- deployments without the optional Tools service omit presentation metadata. */ - if (tools === undefined) return undefined - try { - if (event.type === 'tool/call') { - const data = event.data as ToolCallData - const view: ToolCallView | undefined = tools.get(data.name, scope)?.presentCall?.(JSON.parse(data.arguments)) - return view === undefined ? undefined : { for: 'call', view: jsonView(view) } - } - const [result] = event.data.message.content - const call = argsFor(event.data.message.source.callId) - if (call === undefined) return undefined - const view: ToolResultView | undefined = tools.get(call.name, scope)?.presentResult?.(call.args, { - content: result.content, - isError: result.isError === true, - ...(event.data.meta === undefined ? {} : { meta: event.data.meta }), - }) - return view === undefined ? undefined : { for: 'result', view: jsonView(view) } - } catch (error) { - ctx.logger.warn(`session: presenter failed for ${event.type}: ${String(error)}`) - } - return undefined -} - -function backscanArgs( - events: readonly SessionEvent[], - callId: string, -): { readonly name: string; readonly args: unknown } | undefined { - for (let index = events.length - 1; index >= 0; index--) { - const event = events[index] as SessionEvent - if (event.type !== 'tool/call') continue - const data = event.data as ToolCallData - if (data.callId !== callId) continue - return parseToolCall(data) - } - return undefined -} - -function parseToolCall(data: ToolCallData): { readonly name: string; readonly args: unknown } | undefined { - try { - return { name: data.name, args: JSON.parse(data.arguments) } - } catch { - return undefined - } -} - -function jsonView(view: ToolCallView): SessionToolCallView -function jsonView(view: ToolResultView): ToolResultView -function jsonView(view: ToolCallView | ToolResultView): SessionToolCallView | ToolResultView { - const encoded = JSON.stringify(view) - return JSON.parse(encoded) as SessionToolCallView | ToolResultView -} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b4b6b4c53a..d4c9cbd8e1 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -69,7 +69,6 @@ export class SessionController extends TypertRemoteService { 'llm', 'sessions', 'sessionQuery', - 'tools', 'typert', 'workspaceRegistry', ] @@ -260,7 +259,7 @@ export class SessionController extends TypertRemoteService { /** * Read one cold-safe, message-aligned Session history page. * @param request - durable address, backward cursor, and page budget. - * @param signal - cancellation for persistence and presentation reads. + * @param signal - cancellation for persistence reads. * @returns one chronological page and optional latest projections. */ @Remote('page') diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 56e0d7d91a..adcf8f6c06 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -10,12 +10,6 @@ import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/t import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' -import type { - DiffCallView, - GenericCallView, - TerminalCallView, - ToolResultView, -} from '@deepseek-ai/dsh-tools/presentation' declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { @@ -333,21 +327,9 @@ export type SessionAddress = readonly mode: 'one-shot' | 'continuable' } -/** JSON-safe call render intent crossing the Session Remote boundary. */ -export type SessionToolCallView = - | (Omit & { readonly rawInput?: JsonValue }) - | TerminalCallView - | DiffCallView - -/** Host-computed render intent accompanying one tool event. */ -export type SessionToolView = - | { readonly for: 'call'; readonly view: SessionToolCallView } - | { readonly for: 'result'; readonly view: ToolResultView } - -/** One raw Session event plus its optional transient render intent. */ +/** One raw Session event in the Remote journal. */ export interface SessionEventEntry { readonly event: SessionWireEvent - readonly view?: SessionToolView } /** Session event wire form; durable readers own recognition of merge-extensible event names. */ diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 61b926d93a..f34745e326 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -5,6 +5,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' +import SessionController from '../src/index.ts' import { createSessionTestController } from './test-remote.ts' const defaults = { @@ -13,6 +14,10 @@ const defaults = { } describe('SessionController facade', () => { + it('does not require the Tools service', () => { + expect(SessionController.inject).not.toContain('tools') + }) + it('owns Host service methods and publishes Agent lifecycle projections', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts index 0781621296..6aba942774 100644 --- a/packages/api/session-controller/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -142,7 +142,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s ] } -/** Wrap raw events as view-less history entries (the wire shape history returns). */ +/** Wrap raw events in the journal envelope returned by history. */ export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] { return events.map(event => ({ event })) } diff --git a/packages/api/session-controller/tests/session-history-view.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts similarity index 51% rename from packages/api/session-controller/tests/session-history-view.host.spec.ts rename to packages/api/session-controller/tests/session-history-journal.host.spec.ts index 9de6a00985..b4c442d9f2 100644 --- a/packages/api/session-controller/tests/session-history-view.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -1,38 +1,15 @@ -/** - * Tool-card view computation over Session Controller history and follow: three standard card types - * arrive on the frame, a presenterless tool ships no view field, a call-only - * presenter keeps raw result content out of the view payload, and a throwing - * presenter soft-falls to no view (the event still ships). Result pairing - * works for both paged and live entries. - */ +/** Raw Session journal transport and message-aligned pagination coverage. */ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' import { createSessionTestRemote } from './test-remote.ts' -const reply = (text: string): Promise => Promise.resolve([{ type: 'text', text }]) - -function tool(name: string, presenters: Pick): ToolDefinition { - return defineContentToolFixture({ - name, - description: `tool ${name}`, - parameters: {}, - execute: () => reply(`ran:${name}`), - ...presenters, - }) -} - /** Append a production-shaped human prompt to the session surface. */ function appendUserText(session: Session, text: string): SessionEvent { return session.append('user/message', createUserMessage({ @@ -65,27 +42,7 @@ function appendExtension(session: Session, type: string, data: unknown): Session async function harness(): Promise<{ ctx: Context }> { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - ctx.tools.register(tool('gen', { - presentCall: () => ({ card: 'generic', title: 'gen call' }), - presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }), - })) - ctx.tools.register(tool('term', { - presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }), - presentResult: () => ({ card: 'terminal', output: 'done' }), - })) - ctx.tools.register(tool('diffy', { - presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }), - })) - ctx.tools.register(tool('call-only', { - presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }), - })) - ctx.tools.register(tool('plain', {})) - ctx.tools.register(tool('boom', { - presentCall: () => { throw new Error('presenter exploded') }, - })) return { ctx } } @@ -119,73 +76,37 @@ async function openFollow( return { [Symbol.asyncIterator]: () => iterator } } -describe('Session history view computation', () => { - it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { +describe('Session history raw journal', () => { + it('follows raw tool events and preserves result metadata without a Tools service', async () => { const { ctx } = await harness() const session = ctx.sessions.create() const history = new SessionHistoryController(ctx) const abort = new AbortController() const stream = await openFollow(history, session.id, abort.signal) - const collected = collect(stream, 9, abort) - const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}` - - session.append('turn/start', { turn: 1 }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' }) - session.append('tool/result', { + const collected = collect(stream, 2, abort) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed', + }) + const result = session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ - callId: CallId('c-call-only'), - content: [{ type: 'text', text: rawResult }], - isError: false, - }), - }, { surfaceOp: 'append' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' }) - session.append('tool/result', { - turn: 1, step: 1, - message: createToolResultMessage({ - callId: CallId('c-gen'), - content: [{ type: 'text', text: 'ok' }], + callId: CallId('raw-call'), + content: [{ type: 'text', text: 'raw output' }], isError: false, }), + meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] }, }, { surfaceOp: 'append' }) const frames = await collected - const events = frames.filter(f => f.type === 'event') - const byCall = new Map(events - .filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result') - .map(f => [ - `${f.event.type}:${f.event.type === 'tool/call' - ? (f.event.data as unknown as SessionEvent<'tool/call'>['data']).callId - : (f.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`, - f, - ])) - - expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } }) - expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } }) - expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff') - expect(byCall.get('tool/call:c-call-only')?.view).toEqual({ - for: 'call', - view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }, - }) - const callOnlyResult = byCall.get('tool/result:c-call-only') - expect('view' in (callOnlyResult ?? {})).toBe(false) - const serializedResult = JSON.stringify(callOnlyResult) - expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0) - expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult)) - // No presenter → the frame carries no view property at all. - expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false) - // Throwing presenter → soft-fall: event ships, no view. - expect(byCall.get('tool/call:c-boom')).toBeDefined() - expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false) - // Result pairing through the live table: presentResult saw the call's args. - expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } }) + expect(frames).toEqual([ + { type: 'event', event: call }, + { type: 'event', event: result }, + ]) + expect((frames[1] as Extract).event.data) + .toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } }) }) - it('pairs live results from the open-call table without rescanning Session history', async () => { + it('follows live results without rescanning Session history', async () => { const { ctx } = await harness() const session = ctx.sessions.create() const history = new SessionHistoryController(ctx) @@ -197,7 +118,7 @@ describe('Session history view computation', () => { turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}', }) await expect(iterator.next()).resolves.toMatchObject({ - value: { type: 'event', view: { for: 'call', view: { card: 'terminal', title: 'pwd' } } }, + value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } }, }) const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { @@ -213,7 +134,7 @@ describe('Session history view computation', () => { }), }, { surfaceOp: 'append' }) await expect(iterator.next()).resolves.toMatchObject({ - value: { type: 'event', view: { for: 'result', view: { card: 'terminal', output: 'done' } } }, + value: { type: 'event', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } }, }) } finally { events.mockRestore() @@ -223,53 +144,22 @@ describe('Session history view computation', () => { } }) - it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { + it('serves raw call and result entries without parsing tool arguments', async () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create() - // history resolves the agent first; a live structural stub is enough (only - // .session is read on this path). - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - session.append('turn/start', { turn: 1 }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' }) - // meta rides through to presentResult's ToolResult (the spread arm). - session.append('tool/result', { + const start = session.append('turn/start', { turn: 1 }) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken', + }) + const result = session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ - callId: CallId('h-term'), - content: [{ type: 'text', text: 'ok' }], - isError: false, - }), - meta: { n: 1 }, - }, { surfaceOp: 'append' }) - // Unpaired result: no tool/call with this id anywhere in the page. - session.append('tool/result', { - turn: 1, step: 1, - message: createToolResultMessage({ - callId: CallId('h-orphan'), - content: [{ type: 'text', text: 'x' }], - isError: false, - }), - }, { surfaceOp: 'append' }) - // Paired, but the call's stored arguments do not parse: backscan soft-falls. - session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' }) - session.append('tool/result', { - turn: 1, step: 1, - message: createToolResultMessage({ - callId: CallId('h-bad'), - content: [{ type: 'text', text: 'y' }], - isError: false, - }), - }, { surfaceOp: 'append' }) - // Presenterless tool: pairing succeeds but presentResult is absent. - session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' }) - session.append('tool/result', { - turn: 1, step: 1, - message: createToolResultMessage({ - callId: CallId('h-plain'), - content: [{ type: 'text', text: 'z' }], - isError: false, + callId: CallId('history-call'), + content: [{ type: 'text', text: 'failed raw output' }], + isError: true, }), + meta: { persisted: true, count: 3 }, }, { surfaceOp: 'append' }) const response = await remote.page({ @@ -278,27 +168,17 @@ describe('Session history view computation', () => { }) expect(response.ok).toBe(true) if (!response.ok) throw new Error('unreachable') - const entries = response.value.events - const byKey = new Map(entries - .filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result') - .map(entry => [ - `${entry.event.type}:${entry.event.type === 'tool/call' - ? (entry.event.data as unknown as SessionEvent<'tool/call'>['data']).callId - : (entry.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`, - entry, - ])) - expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } }) - expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } }) - expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false) - expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false) - expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false) + expect(response.value.events).toEqual([ + { event: start }, + { event: call }, + { event: result }, + ]) }) it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create() - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) const first = appendUserText(session, 'first prompt') appendAssistantText(session, 'first reply', 1) @@ -348,7 +228,6 @@ describe('Session history view computation', () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create() - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', { turn: 1, @@ -384,28 +263,41 @@ describe('Session history view computation', () => { } }) - it('pairs a followed result after turn/end from the addressed Session log', async () => { + it('follows a result after turn/end without reading the addressed Session log', async () => { const { ctx } = await harness() const session = ctx.sessions.create() const history = new SessionHistoryController(ctx) const abort = new AbortController() const stream = await openFollow(history, session.id, abort.signal) - const collected = collect(stream, 4, abort) + const iterator = stream[Symbol.asyncIterator]() session.append('turn/start', { turn: 1 }) + await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' }) + await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('tool/result', { - turn: 1, step: 1, - message: createToolResultMessage({ - callId: CallId('c-late'), - content: [{ type: 'text', text: 'ok' }], - isError: false, - }), - }, { surfaceOp: 'append' }) - - const frames = await collected - const result = frames.find(f => f.type === 'event' && f.event.type === 'tool/result') - expect(result?.type === 'event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } }) + const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + throw new Error('live result rescanned Session history') + }) + try { + const result = session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: CallId('c-late'), + content: [{ type: 'text', text: 'ok' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { type: 'event', event: result }, + }) + } finally { + events.mockRestore() + abort.abort() + await iterator.next() + await ctx.fiber.dispose() + } }) }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 17533bb585..aa8f26de24 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types' import { Session, type SessionOptions } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { entries, ev, plainTurn } from './event-script.client.ts' @@ -26,12 +25,10 @@ function makeSession( function follow( api: FakeApiClient, event: SessionEvent, - view?: SessionToolView, ): Promise { return api.pushFollow(SID, { type: 'event', event: event as never, - ...(view === undefined ? {} : { view }), }) } @@ -44,7 +41,7 @@ function eventSeqs(session: Session): number[] { } function histResponse(events: SessionEvent[], hasMore = false) { - // history returns HistoryEntry[] ({event, view?}); these tests are view-less. + // History returns raw journal envelopes around each event. return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) } @@ -601,39 +598,30 @@ describe('remaining branches', () => { await expect(session.dispose()).resolves.toBeUndefined() }) - it('carries history-entry and follow-frame views through the event feed', async () => { + it('carries raw history and follow events through the event feed', async () => { const { api, session } = makeSession() - const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } } + const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}') + const historyResult = ev.toolResult(7, 1, 'h1', 'done') api.onHistory = () => Promise.resolve(ok({ events: [ ...entries(plainTurn(0, 0, 'a', 'b')), - { event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView }, - { event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } }, + { event: historyCall }, + { event: historyResult }, ] as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) await session.open() - expect(windowEntries(session).slice(-2).map(item => item.view)).toEqual([ - callView, - { for: 'result', view: { card: 'generic', title: '历史果' } }, + expect(windowEntries(session).slice(-2)).toEqual([ + { event: historyCall }, + { event: historyResult }, ]) - await follow( - api, - ev.toolCall(8, 2, 'l1', 'write', '{}'), - { for: 'call', view: { card: 'generic', title: '直播卡' } }, - ) - expect(windowEntries(session).at(-1)?.view).toEqual({ - for: 'call', view: { card: 'generic', title: '直播卡' }, - }) - await follow( - api, - ev.toolResult(9, 2, 'l1', 'ok'), - { for: 'result', view: { card: 'generic', title: '直播果' } }, - ) - expect(windowEntries(session).at(-1)?.view).toEqual({ - for: 'result', view: { card: 'generic', title: '直播果' }, - }) + const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}') + await follow(api, liveCall) + expect(windowEntries(session).at(-1)).toEqual({ event: liveCall }) + const liveResult = ev.toolResult(9, 2, 'l1', 'ok') + await follow(api, liveResult) + expect(windowEntries(session).at(-1)).toEqual({ event: liveResult }) }) }) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 7bdb426e9d..c30d3f095f 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -482,60 +482,6 @@ describe('SessionHistoryController', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed')) }) - it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => { - const live = await setup() - const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } }) - const liveAgent = { id: liveSession.id } - const preset = vi.fn(() => Promise.resolve('preset-scope')) - live.ctx.provide('agents', { get: () => liveAgent } as never) - live.ctx.provide('agentPresets', { standingKeyFor: preset } as never) - await live.transport.page({ - address: { kind: 'session', sessionId: liveSession.id }, throughSeq: -1, - }, signal()) - expect(preset).not.toHaveBeenCalled() - - const attached = await setup() - const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), { - meta: { cwd: '/workspace', agentPreset: 'minimal' }, - }) - const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope')) - attached.ctx.provide('agentPresets', { standingKeyFor } as never) - await attached.transport.page({ - address: { kind: 'session', sessionId: attachedSession.id }, - throughSeq: -1, - }, signal()) - expect(standingKeyFor).toHaveBeenCalledWith('minimal') - - const detached = await setup() - const detachedId = SessionId('detached-scope') - const header = { - version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard', - } - cold(detached.ctx, header, []) - const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable'))) - detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never) - await expect(detached.transport.page({ - address: { kind: 'session', sessionId: detachedId }, - throughSeq: -1, - }, signal())).resolves.toMatchObject({ events: [] }) - expect(rejected).toHaveBeenCalledWith('standard') - - const switched = await setup() - const switchedId = SessionId('switched-scope') - const switchedHeader = { - version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard', - } - cold(switched.ctx, switchedHeader, [ - event('agent-preset/selected', 0, { agentPreset: 'minimal' }), - ]) - const switchedKey = vi.fn(() => Promise.resolve('switched-scope')) - switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never) - await switched.transport.page({ - address: { kind: 'session', sessionId: switchedId }, throughSeq: 0, - }, signal()) - expect(switchedKey).toHaveBeenCalledWith('minimal') - }) - it('keeps message-aligned pagination contiguous across replacement provenance', async () => { const { ctx, transport } = await setup() const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } }) @@ -576,77 +522,4 @@ describe('SessionHistoryController', () => { expect(page.hasMore).toBe(false) }) - it('projects tool call and result views and contains malformed presenters', async () => { - const { ctx, transport } = await setup() - const sessionId = SessionId('presenters') - const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } - const events = [ - event('fixture/start', 0), - event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }), - event('tool/result', 2, { - message: { - source: { callId: 'c1' }, - content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }], - }, - meta: { persisted: true }, - }), - event('tool/result', 3, { - message: { - source: { callId: 'missing' }, - content: [{ content: [{ type: 'text', text: 'missing' }] }], - }, - }), - event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }), - event('tool/result', 5, { - message: { - source: { callId: 'c2' }, - content: [{ content: [{ type: 'text', text: 'bad args' }] }], - }, - }), - event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }), - event('tool/result', 7, { - message: { - source: { callId: 'c3' }, - content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }], - }, - }), - event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }), - event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }), - event('tool/result', 10, { - message: { - source: { callId: 'c5' }, - content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }], - }, - }), - ] - cold(ctx, header, events) - ctx.provide('tools', { - get: (name: string) => { - if (name === 'present') { - return { - presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }), - presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }), - } - } - if (name === 'empty') return {} - if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } } - if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } } - return undefined - }, - } as never) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - - const page = await transport.page({ - address: { kind: 'session', sessionId }, throughSeq: 10, - }, signal()) - expect(page.events[1]?.view).toEqual({ - for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } }, - }) - expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } }) - for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) { - expect(page.events[index]).not.toHaveProperty('view') - } - expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed')) - }) }) diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 838fa6a03c..648055bcee 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -21,7 +21,6 @@ { "path": "../../llm/llm" }, { "path": "../../session/session-projection" }, { "path": "../../session/session-title" }, - { "path": "../../core/tools" }, { "path": "../../util/brand" }, { "path": "../../util/crypto" }, { "path": "../../util/workspace-path" }, diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index efceb803df..a778326d62 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -24,7 +24,6 @@ { "path": "../../core/agent-default-model" }, { "path": "../../core/scope" }, { "path": "../../core/session" }, - { "path": "../../core/tools" }, { "path": "../../attachment/attachment" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 478468f637..95ce0d1981 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -55,8 +55,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tool-todo": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", @@ -67,7 +66,6 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tool-todo": "workspace:^" } } diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index e712ca5c3c..2c899ef0e7 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -17,7 +17,6 @@ export type { CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, } from '@deepseek-ai/dsh-host-apiproxy/api' -export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, RpcMessage, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8d6c16cfce..22a0f64e55 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -17,6 +17,7 @@ import type { } from '@deepseek-ai/dsh-llm' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { + JsonValue, SessionEvent, SessionId, } from '@deepseek-ai/dsh-session/types' @@ -29,7 +30,6 @@ import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surfac import type { ApiProxy, ClientRequest, ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse, - ToolCallView, ToolResultView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId } from './api.ts' @@ -71,13 +71,8 @@ interface FixtureProjectionsBlock { readonly values: Readonly> } -type FixtureToolView = - | { readonly for: 'call'; readonly view: ToolCallView } - | { readonly for: 'result'; readonly view: ToolResultView } - interface FixtureHistoryEntry { readonly event: SessionEvent - readonly view?: FixtureToolView } type FixtureSessionAddress = @@ -362,11 +357,9 @@ function sgr(code: number, body: string): string { * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll * rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the - * height cap collapses the middle. The exit status is authored separately in - * TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash - * presenter CONSUMES its `[exit code: N]` marker out of the body, because a - * terminal card shows the exit as its own pill and leaving the marker in would - * render it twice (packages/shell/tool-bash/src/render.ts). + * height cap collapses the middle. This constant is the visible body; the call + * site appends the shell result's `[exit code: N]` marker so Client derivation + * can consume it into the terminal status pill. */ const TERMINAL_OUTPUT_FIXTURE = [ sgr(1, 'Running 4 checks'), @@ -393,20 +386,10 @@ const TERMINAL_OUTPUT_FIXTURE = [ ].join('\n') /** - * Exit status for each terminal sample, keyed by its output text. Authored - * alongside the sample rather than parsed back out of its trailing marker, - * which is the bash tool's own job and not something to reimplement here. - */ -const TERMINAL_EXIT_STATUS: Record = { - [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 }, -} - -/** - * Structured grep result for the search sample (turn 67): matches grouped by - * file, authored inline because the client-side fixture cannot import the tool - * that produces the canonical value. `truncated` with a larger `total` than the - * retained match count exercises the search card's capped indicator; the file - * with more than CHAT_SEARCH_MAX_LINES rows exercises its head/tail height cap. + * Structured grep metadata for the search sample (turn 67). `truncated` with a + * larger `total` than the retained match count exercises the search card's + * capped indicator; the file with more than CHAT_SEARCH_MAX_LINES rows + * exercises its head/tail height cap. */ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [ { @@ -475,18 +458,23 @@ const READ_SAMPLE_SOURCE = [ const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text })) const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx' const READ_SAMPLE_TOTAL = 180 -const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n') +const READ_SAMPLE_LAST_LINE = READ_SAMPLE_FIRST_LINE + READ_SAMPLE_SOURCE.length - 1 +const READ_SAMPLE_TEXT = [ + `${READ_SAMPLE_PATH}`, + 'file', + '', + ...READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`), + '', + `(Showing lines ${READ_SAMPLE_FIRST_LINE}-${READ_SAMPLE_LAST_LINE} of ${READ_SAMPLE_TOTAL}. Use offset=${READ_SAMPLE_LAST_LINE + 1} to continue.)`, + '', +].join('\n') /** - * The structured `web_search` result view for the web-search turn, authored inline - * because this client-side fixture cannot import the web tool that projects it. - * The sources exercise the citation list's features: a titled source with a - * snippet and a date, a source with no title (its hostname labels the link) and - * a snippet but no date, and a source with a title and a date but no snippet. - * `truncated` marks the capped indicator. The shape is the contract's own - * search view minus its wire discriminants. + * The `web_search` result metadata for the web-search turn. The sources cover a + * titled source with a snippet and date, a hostname-label fallback, and a + * titled source without a snippet; `truncated` exercises the capped indicator. */ -const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = { +const WEB_SEARCH_META = { answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', sources: [ { @@ -506,14 +494,14 @@ const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = { +/** The `web_fetch` result metadata for the web-fetch turn. */ +const WEB_FETCH_META = { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200, truncated: false, -} +} satisfies JsonValue const DEEPSEEK_REASONING = { efforts: [ @@ -649,10 +637,16 @@ function buildAlphaLog(): SessionEvent[] { } push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in - // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above - // stays presenter-less as the unknown fallback. - const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { + // The structured samples use real first-party names and result metadata so + // the fixture follows the same event-to-card path as a persisted Session. + // `echo` above remains the unknown-tool fallback. + const toolTurn = ( + turn: number, + name: string, + args: string, + resultText: string, + resultMeta?: JsonValue, + ): void => { const callId = `fx-call-${turn}` push({ type: 'turn/start', data: { turn } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) }) @@ -662,23 +656,66 @@ function buildAlphaLog(): SessionEvent[] { data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) }, }) push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } }) - push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } }) + push({ + type: 'tool/result', + surfaceOp: 'append', + data: { + turn, + step: 0, + message: toolResultMessage(callId, text(resultText), false), + ...resultMeta === undefined ? {} : { meta: resultMeta }, + }, + }) push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } // A two-line command, so the fixture covers the terminal card's one-row-per- // command-line prompt (and that the card still marks the call exactly once). - toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') - toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') - toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') - toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') + toolTurn( + 60, + 'bash', + '{"command":"ls -la\\necho done","description":"fixture 终端样本","workdir":"/tmp/fixture"}', + 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt', + ) + toolTurn( + 61, + 'write', + '{"file_path":"notes/demo.txt","content":"hello fixture\\n"}', + 'wrote notes/demo.txt', + { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'hello fixture\n' }] }, + ) + toolTurn( + 62, + 'edit', + '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', + '已编辑', + { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] }, + ) + toolTurn( + 63, + 'write', + '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', + '已写入', + { diffs: [{ path: 'notes/new-demo.txt', oldText: null, newText: 'hello fixture\n' }] }, + ) // Turn 64: a multi-hunk edit — two scattered replacements in one file. Named // `edit` so it lands on the keyed FileMutationRow (the resident diff card the - // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker - // the presenter reads to emit the two-hunk sample: the card draws one path - // header, the first hunk, a `⋯` gap, then the second (the same-file + // single-hunk turn 62 also uses). Its result metadata carries two scattered + // hunks under one path header, so the card draws the first hunk, a `⋯` gap, + // then the second (the same-file // second-hunk arm turns 62/63 cannot reach). - toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑') + toolTurn( + 64, + 'edit', + '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', + '已编辑', + { + diffs: [ + { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' }, + { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' }, + ], + }, + ) // Turn 65: one run_code turn with three logged sub-dispatches — the Code // Mode acceptance surface (parent code row + nested native-identical rows, // including an isError sub-call and a bash sub-call that must hit the same @@ -734,52 +771,75 @@ function buildAlphaLog(): SessionEvent[] { ] // Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover — // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd - // whose prompt label is its last segment, and a non-zero exit authored beside - // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no - // `[exit code: N]` marker, since the real presenter consumes that one out of - // the body. Named `bash`, so it also covers - // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback - // row) — the two chat-row shapes the terminal card renders in. + // whose prompt label is its last segment, and a non-zero exit. The raw result + // includes an `[exit code: N]` marker below; Client + // derivation consumes it into the status pill before rendering the body. // // Ordered BEFORE the todo turn deliberately: the standing plan retires at the // next `turn/start`, so a turn appended after it would leave the dock's plan // strip empty and take the todo surfaces' own coverage with it. - toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + toolTurn( + 66, + 'bash', + '{"command":"pnpm run check","description":"fixture 终端样本","workdir":"/tmp/fixture/deep/nested"}', + `${TERMINAL_OUTPUT_FIXTURE}\n[exit code: 1]`, + ) - // Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'` - // `shape: 'matches'` result view (grouped-by-file matches, truncated with a - // larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise - // truncated). Both ride the keyed SearchRow registration under their own - // names; the render-site fallback row is covered by the model derivation - // tests, since every fixture search tool has a keyed row. Ordered before the - // todo turn for the same standing-plan reason the bash turn is. - toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) - toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) + // Turns 67-68 carry the search card's two metadata variants: grouped matches + // and a flat path list, both truncated with a larger pre-cap total. Both use + // the keyed SearchRow registration. They stay before the todo turn for the + // same standing-plan reason as the bash turn. + toolTurn( + 67, + 'grep', + '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', + SEARCH_MATCHES_TEXT, + { shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }, + ) + toolTurn( + 68, + 'glob', + '{"pattern":"**/SearchBlock*","path":"packages/client"}', + SEARCH_PATHS_TEXT, + { shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }, + ) // Turn 69: the read sample — a WINDOW past an offset so the card draws file // line numbers starting above 1 and a "showing N of M" note (the window is // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path // highlights. Named `read`, so it exercises the keyed ReadRow registration. - // The render-site fallback ROW SHAPE (a read call on the generic flattened - // path) is covered by the turn 65 run_code read sub-dispatches, which - // session.ts folds with resultView: null; the fallback-row + read-CARD - // combination is pinned by the web_fetch case in read-card.spec.tsx, not by - // this fixture. The read render intent is result-side only, so its pending - // call stays a generic `kind: 'read'` card; presentResult carries the - // structured window. - toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) + // The run_code sub-dispatches above cover nested read calls without result + // metadata; this top-level result carries the structured window. + toolTurn( + 69, + 'read', + `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, + READ_SAMPLE_TEXT, + { + path: READ_SAMPLE_PATH, + offset: READ_SAMPLE_FIRST_LINE, + lines: READ_SAMPLE_LINES, + totalLines: READ_SAMPLE_TOTAL, + lang: 'ts', + }, + ) - // Turns 70-71: the web render intent — a web_search whose result view carries - // structured sources plus an answer (the citation list, one source lacking a - // title so its hostname labels the link, the capped indicator on), and a - // web_fetch whose result view carries the fetched URL and its HTTP status. - // Both keep a generic pending call view and add the `web` card only at - // result time, which is the contract's result-only web shape. Named after - // the real tools so they hit the keyed WebRow registration. Ordered BEFORE - // the todo turn for the same reason turn 66 is: the standing plan retires at - // the next turn/start, so a turn after it would empty the dock's plan strip. - toolTurn(70, 'web_search', '{"queries":["deepseek harness architecture"]}', 'Search results for deepseek harness architecture.') - toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + // Turns 70-71 carry the web tools' result metadata. They stay before the todo + // turn because a later turn/start retires the standing plan projection. + toolTurn( + 70, + 'web_search', + '{"queries":["deepseek harness architecture"]}', + 'Search results for deepseek harness architecture.', + WEB_SEARCH_META, + ) + toolTurn( + 71, + 'web_fetch', + '{"url":"https://www.deepseek.com/blog/harness-architecture"}', + '# Harness architecture\n\nEverything is a plugin.', + WEB_FETCH_META, + ) // Turn 72: max-tokens sample — the provider ends the turn at its output cap // mid-sentence, so the chat flow must render the turn-max-tokens notice @@ -832,151 +892,6 @@ function buildAlphaLog(): SessionEvent[] { return events as unknown as SessionEvent[] } -/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */ -/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */ -const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback - -/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */ -function presentCall(name: string, argsRaw: string): ToolCallView | undefined { - let args: Record - try { - args = JSON.parse(argsRaw) as Record - } catch { - /* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */ - return undefined - } - switch (name) { - // Both names present the same terminal card: `fx-bash` lands on the - // render-site fallback row, `bash` on the keyed BashRow registration. - case 'fx-bash': - case 'bash': - return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' } - case 'fx-write': - return { - card: 'diff', title: `Write ${str(args.path)}`, - diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }], - } - // A read pending call is a GENERIC card (kind: 'read', a follow-along - // location): the read render intent is result-side only, because a call - // carries no file content until execute returns. The rich read card arrives - // in presentResult. - case 'read': - return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] } - case 'edit': - // The multi-hunk sample (turn 64) is keyed on its file_path, so the two - // scattered hunks share one path header and the card draws the `⋯` gap. - if (str(args.file_path) === 'src/config.ts') { - return { - card: 'diff', title: `Edit ${str(args.file_path)}`, - diffs: [ - { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' }, - { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' }, - ], - } - } - return { - card: 'diff', title: `Edit ${str(args.file_path)}`, - diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }], - } - case 'write': - return { - card: 'diff', title: `Write ${str(args.file_path)}`, - diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }], - } - // A search call stays a generic card (kind: 'search'): the structured - // matches/paths exist only after execute, so the search card is result-time - // only (presentResult builds it). This mirrors the real grep/glob presenters. - case 'grep': - return { card: 'generic', title: `Grep ${str(args.pattern)}`, kind: 'search', rawInput: args } - case 'glob': - return { card: 'generic', title: `Glob ${str(args.pattern)}`, kind: 'search', rawInput: args } - // The web tools keep a GENERIC pending card and add the `web` result card - // only at result time (the contract's result-only web shape); their pending - // kind matches the result kind so a call and its result read as one category. - case 'web_search': { - const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string' && query !== '') : [] - const title = queries.join(', ') - return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args } - } - case 'web_fetch': - return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args } - default: - return undefined // echo et al: the documented no-view fallback path - } -} - -function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined { - const call = presentCall(name, argsRaw) - if (call === undefined) return undefined - // Search is result-time only: the call stays a generic search card, and the - // result view carries the structured shape the card renders. The view holds no - // result text — a UI without a search card falls back to the raw tool/result - // content — so the truncation recovery footer rides that raw content (the - // `toolTurn` message text), not the view. `total` exceeds the retained count so - // the card shows its capped indicator. - if (name === 'grep') { - return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 } - } - if (name === 'glob') { - return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 } - } - // The read result is the structured window the tool projects through - // `presentationMeta`; the fixture authors it inline (it cannot import the - // tool). Keyed on the name because the read pending call is a generic card, - // so `call.card` alone does not distinguish it from edit/write. - if (name === 'read') { - return { - card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES, - totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText), - } - } - // The web tools keep a generic pending card, so their result card is chosen - // by tool name rather than by the pending card tag: the structured `web` card - // the frontend consumes. The view carries no `content` copy (per the contract - // and the web-result-card note); a capability-less UI falls back to the raw - // `tool/result` content, which this fixture emits from `resultText`. - if (name === 'web_search') { - return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT } - } - if (name === 'web_fetch') { - return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT } - } - switch (call.card) { - case 'terminal': - // The sample's own exit status, authored beside it: re-parsing the - // trailing marker here would duplicate the bash tool's `parseExitStatus`, - // which this client-side fixture cannot import. - return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) } - case 'diff': - return { card: 'diff', diffs: call.diffs } - case 'generic': - return { card: 'generic', content: text(resultText) } - } -} - -/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */ -function viewFor(event: SessionEvent, log: readonly SessionEvent[]): FixtureToolView | undefined { - if (event.type === 'tool/call') { - const view = presentCall(event.data.name, event.data.arguments) - return view === undefined ? undefined : { for: 'call', view } - } - if (event.type === 'tool/result') { - const callId = String(event.data.message.source.callId) - for (let i = log.length - 1; i >= 0; i--) { - const candidate = log[i] - /* v8 ignore next -- dense-array guard: i stays within [0, log.length), - so the undefined arm needs a sparse log no code path builds. */ - if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) { - const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('') - const view = presentResult(candidate.data.name, candidate.data.arguments, resultText) - return view === undefined ? undefined : { for: 'result', view } - } - } - return undefined // cross-page unpaired: documented default - } - return undefined -} - /** * Fixture parallel of the plan unit's lifecycle fold. The paired * `command/done` retains successful plan selections and drops failures; @@ -1422,11 +1337,9 @@ function projectionFramesOf( } /** - * Message-boundary paging (mirrors the host's paging contract): count - * maxMessages messages - * backwards from end, cut at a turn/start boundary. - Entries carry pagination-time views - * (the host analogue computes viewFor per entry at page time). */ + * Message-boundary paging mirrors the Host contract: count `maxMessages` + * backwards from the end and cut at a turn/start boundary. + */ function pageOf( log: readonly SessionEvent[], beforeSeq: number | undefined, @@ -1445,10 +1358,7 @@ function pageOf( break } } - const events = log.slice(start, end).map((event): FixtureHistoryEntry => { - const view = viewFor(event, log) - return view === undefined ? { event } : { event, view } - }) + const events = log.slice(start, end).map((event): FixtureHistoryEntry => ({ event })) return { events, hasMore: start > 0 } } @@ -1974,12 +1884,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const log = logOf(id) const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent log.push(event) - // Emission-time view derivation (mirrors the host's live path). - const view = viewFor(event, log) - /* v8 ignore next 2 -- the view-present arm needs a live tool/call emission, - but the fixture replay produces text-only turns; view vocabulary is - exercised through the history samples (turns 60-62). */ - emitFollow(id, view === undefined ? { event } : { event, view }) + emitFollow(id, { event }) // Host eager-drive parallel: a unit-advancing event pushes its finished value. for (const frame of projectionFramesOf(id, log, event)) emitControl(frame) if (event.type === 'user/message' && event.data.source.kind === 'user') { @@ -3007,8 +2912,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`) } nextSeq++ - const view = viewFor(event, snapshot) - yield view === undefined ? { type: 'event', event } : { type: 'event', event, view } + yield { type: 'event', event } } } for await (const frame of conn.drain(signal)) { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c56bf17c84..438303580f 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -32,7 +32,6 @@ declare module '@deepseek-ai/cordis' { export type { ApiProxy, HostApi, DirectoryEntry, DirectoryListing, - ToolCallView, ToolResultView, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 58c347fec3..814fe53aa8 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -37,7 +37,6 @@ interface FixtureSessionSummary { interface FixtureHistoryEntry { readonly event: SessionEvent - readonly view?: unknown } interface FixturePage { @@ -648,6 +647,49 @@ describe('createFixtureApi', () => { }) }) + it('serves raw history entries with replayable tool-result metadata', async () => { + const api = createFixtureApi() + const response = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 200 })) + if (!response.result.ok) throw new Error('history failed') + + const entries = response.result.value.events + expect(entries.every(entry => !Object.hasOwn(entry, 'view'))).toBe(true) + const results = entries + .map(entry => entry.event) + .filter(event => event.type === 'tool/result') + + expect(results.find(event => event.data.turn === 64)).toMatchObject({ + data: { + meta: { + diffs: [ + { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' }, + { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' }, + ], + }, + }, + }) + expect(results.find(event => event.data.turn === 67)).toMatchObject({ + data: { meta: { shape: 'matches', truncated: true, total: 42 } }, + }) + expect(results.find(event => event.data.turn === 69)).toMatchObject({ + data: { meta: { path: 'packages/client/ui-primitives/src/ReadBlock.tsx', offset: 41, totalLines: 180 } }, + }) + const webSearch = results.find(event => event.data.turn === 70) + expect(webSearch).toHaveProperty('data.meta.truncated', true) + expect(webSearch).toHaveProperty('data.meta.sources', expect.arrayContaining([ + expect.objectContaining({ url: 'https://github.com/deepseek-ai/deepseek-harness' }), + ])) + expect(results.find(event => event.data.turn === 71)).toMatchObject({ + data: { meta: { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200 } }, + }) + const terminal = results.find(event => event.data.turn === 66) + expect(terminal).toHaveProperty('data.message.content.0.content.0.type', 'text') + expect(terminal).toHaveProperty( + 'data.message.content.0.content.0.text', + expect.stringContaining('\n[exit code: 1]'), + ) + }) + it('serves grouped models and keeps a selection for later history and fixture requests', async () => { const api = createFixtureApi() const sessionId = sid('fx-alpha') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a30fa4f902..f5f3e827dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1059,9 +1059,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -1806,9 +1803,6 @@ importers: '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools packages/client/hmr: dependencies: From a4c296f9fe1be6692053276426c49ee5d1e9a279 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:12:22 +0800 Subject: [PATCH 010/138] refactor(client): derive tool cards from raw events --- .../src/client/conversation-nodes/tool.ts | 11 +- .../src/client/details/DetailsPanel.tsx | 5 +- .../src/client/model/tool-call-tree.ts | 5 +- .../ui-chat/tests/chat-stats.client.spec.tsx | 4 +- .../ui-chat/tests/chat-view.client.spec.tsx | 11 +- ...nversation-node-definitions.client.spec.ts | 23 +- .../tests/gate-branch-tails.client.spec.tsx | 15 +- .../tests/tool-call-tree.client.spec.ts | 6 +- .../src/client/contract/conversation.ts | 4 +- .../src/client/contract/records.ts | 13 +- .../src/client/conversation/assembler.ts | 2 +- .../src/client/conversation/assembly.ts | 5 +- .../ui-skill/tests/skill-row.client.spec.tsx | 5 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 4 +- .../src/client/tool/models/diff-card-model.ts | 92 ++--- .../src/client/tool/models/raw-tool-call.ts | 64 ++++ .../src/client/tool/models/read-card-model.ts | 111 +++--- .../client/tool/models/search-card-model.ts | 200 ++++------- .../client/tool/models/terminal-card-model.ts | 182 ++++++---- .../src/client/tool/models/tool-call-model.ts | 5 +- .../src/client/tool/models/web-card-model.ts | 142 ++++---- .../client/tool/toolviews/GenericToolCard.tsx | 2 +- .../src/client/tool/toolviews/bash-sample.tsx | 6 +- .../src/client/tool/toolviews/search-row.tsx | 2 +- .../tests/ask-question-row.client.spec.tsx | 4 +- .../tests/assembly-surfaces.client.spec.tsx | 12 +- .../tests/chat-code-subcalls.client.spec.tsx | 12 +- .../tests/coverage-tails.client.spec.tsx | 8 +- .../ui-tool/tests/diff-card.client.spec.tsx | 137 ++++---- .../ui-tool/tests/read-card.client.spec.tsx | 103 +++--- .../ui-tool/tests/search-card.client.spec.tsx | 164 +++++---- .../tests/terminal-card.client.spec.tsx | 330 +++++++++--------- .../ui-tool/tests/todo-row.client.spec.tsx | 6 +- .../tests/tool-call-tree.client.spec.tsx | 25 +- .../tests/tool-details-render.client.tsx | 10 +- .../ui-tool/tests/tool-row.client.spec.tsx | 4 +- .../tests/toolview-slot.client.spec.tsx | 2 +- .../ui-tool/tests/web-card.client.spec.tsx | 93 +++-- .../src/client/trajectory-tool-definition.ts | 11 +- .../conversation-definitions.client.spec.ts | 40 ++- .../tests/layout.client.spec.tsx | 18 +- .../tests/snapshot-builder.client.spec.ts | 1 - .../ui-trajectory/tests/views.client.spec.tsx | 2 +- .../ui-cordis/tests/card-model.client.spec.ts | 4 +- .../ui-cordis/tests/versioning.client.spec.ts | 4 - 45 files changed, 1004 insertions(+), 905 deletions(-) create mode 100644 packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index 20ce57831f..b1f675c4e4 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -45,7 +45,6 @@ function rootCall(match: ConversationMatch): RunningToolCall { turn: match.event.data.turn, step: match.event.data.step, time: match.event.time, - callView: match.view?.for === 'call' ? match.view.view : null, subCalls: [], } } @@ -64,8 +63,6 @@ function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolR isError: result.isError === true, ...match.event.data.error === undefined ? {} : { error: match.event.data.error }, meta: match.event.data.meta, - callView: previous?.callView ?? null, - resultView: match.view?.for === 'result' ? match.view.view : null, subCalls: [], } } @@ -82,12 +79,12 @@ interface DispatchData { function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { return { callId: data.subCallId, + parentCallId: data.parentCallId, name: data.name, argsRaw: jsonArguments(data.arguments), turn: locationTurn(match), step: locationStep(match), time: match.event.time, - callView: null, subCalls: [], } } @@ -98,12 +95,11 @@ function childResult(match: ConversationMatch, data: DispatchData, previous?: To seq: match.event.seq, time: match.event.time, callId: data.subCallId, + parentCallId: data.parentCallId, call: { name: data.name, argsRaw: jsonArguments(data.arguments) }, callTime: previous?.time ?? null, content: data.content ?? [], isError: data.isError === true, - callView: null, - resultView: null, subCalls: [], } } @@ -197,13 +193,12 @@ function projectBlock( seq: interruptedAt.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup, time: interruptedAt.time, callId: block.callId, + ...block.parentCallId === undefined ? {} : { parentCallId: block.parentCallId }, call: { name: block.name, argsRaw: block.argsRaw }, callTime: block.time, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, - callView: block.callView, - resultView: null, subCalls: children, } projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected }) diff --git a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx index d3109076f8..05c997e1fe 100644 --- a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx +++ b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx @@ -47,8 +47,8 @@ function rawResultText(block: ToolCallBlock): string { export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) { const selection = useStore(s => s.selection) - // Session workspace root: an omitted or relative terminal cwd resolves - // against it, which the pure presenter cannot see. + // Session workspace root: a card model resolves omitted or relative + // tool paths against it without reading Session services. const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd) const callId = selection?.callId // materialFor builds a fresh wrapper; shallowEqual short-circuits on its @@ -56,7 +56,6 @@ export function DetailsPanel({ useChat, useSessions, sessionId, useStore, render const material = useChat( s => (callId === undefined ? null : materialFor(s, callId)), (a, b) => shallowEqual(a, b)) - return (

diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 705bc481dc..ce311f5291 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -59,12 +59,12 @@ export class ToolCallTree { const data = event.data const running: RunningToolCall = { callId: data.subCallId, + parentCallId: data.parentCallId, name: data.name, argsRaw: JSON.stringify(data.arguments), turn: 0, step: 0, time: event.time, - callView: null, subCalls: [], } const siblings = this.childrenByParent.get(data.parentCallId) ?? [] @@ -84,12 +84,11 @@ export class ToolCallTree { seq: event.seq, time: event.time, callId: data.subCallId, + parentCallId: data.parentCallId, call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, callTime: started?.time ?? null, content: data.content, isError: data.isError, - callView: null, - resultView: null, subCalls: [], } this.childrenByParent.set( diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 1600ccfdef..2332c35517 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -78,7 +78,7 @@ describe('deriveStats', () => { it('ignores tool results with no call time', () => { const tool: ToolResultNode = { kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], - isError: false, callView: null, resultView: null, subCalls: [], + isError: false, subCalls: [], } const stats = deriveStats([tool, assistant(1, 1)]) expect(stats.steps).toBe(1) @@ -96,7 +96,7 @@ describe('deriveStats', () => { } const tool: ToolResultNode = { kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [], - isError: false, callView: null, resultView: null, subCalls: [], + isError: false, subCalls: [], } const stats = deriveStats([timed, untimed, tool]) expect(stats.llmMs).toBe(2_500) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 30f25e2277..d6ccc631e6 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -137,10 +137,10 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, subCalls: [], + content: [], isError: false, subCalls: [], }) const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ - callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [], + callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [], }) const command = (over: Partial = {}): CommandNode => ({ kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], @@ -361,17 +361,14 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien describe('Chat node rendering', () => { it('threads the injected file-mention vocabulary into the closing prose only', () => { - const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ + const wrote = (seq: number, callId: string): ToolResultNode => ({ ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], - }, }) const h = makeHarness({ nodes: [ user(1, 'build it'), assistant(2, 'writing `report.html` now', 1), - wrote(3, 'w', 'site/report.html'), + wrote(3, 'w'), assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1), ], turnEnds: new Map([[1, 4]]), diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 8b3b0f5eea..88b103ae66 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -106,7 +106,7 @@ function assistantMessage(id: string, text: string) { } } -function toolResult(callId: string, text: string) { +function toolResult(callId: string, text: string, isError = false) { return { id: `result-${callId}`, role: 'user', @@ -115,7 +115,7 @@ function toolResult(callId: string, text: string) { type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }], - isError: false, + isError, }], } } @@ -310,7 +310,9 @@ describe('built-in conversation node Definitions', () => { value.append(at(4, 'tool/result', { turn: 1, step: 1, - message: toolResult('root', 'done'), + message: toolResult('root', 'done', true), + error: { name: 'ToolError', code: 'failed' }, + meta: { presentation: 'raw' }, }, { surfaceOp: 'append' })) value.flush() @@ -318,7 +320,15 @@ describe('built-in conversation node Definitions', () => { const settled = node(settledSnapshot, 'tool-call') expect(settled?.key).toBe(running?.key) expect(settledSnapshot.order).toBe(order) - expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' }) + expect((settled?.data as ToolChatData).root).toMatchObject({ + kind: 'tool-result', + callId: 'root', + call: { name: 'code', argsRaw: '{}' }, + content: [{ type: 'text', text: 'done' }], + isError: true, + error: { name: 'ToolError', code: 'failed' }, + meta: { presentation: 'raw' }, + }) const history = assembler([ at(14, 'tool/code-dispatch-start', { @@ -345,7 +355,7 @@ describe('built-in conversation node Definitions', () => { ], true) const before = node(snapshot(history), 'tool-call') expect((before?.data as ToolChatData).root.subCalls).toMatchObject([ - { kind: 'tool-result', callId: 'child', call: { name: 'read' } }, + { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } }, ]) history.prepend([ @@ -364,7 +374,7 @@ describe('built-in conversation node Definitions', () => { const after = node(snapshot(history), 'tool-call') expect(after?.key).toBe(before?.key) expect((after?.data as ToolChatData).root.subCalls).toMatchObject([ - { kind: 'tool-result', callId: 'child', call: { name: 'read' } }, + { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } }, ]) const firstChild = (after?.data as ToolChatData).root.subCalls[0] @@ -1011,7 +1021,6 @@ describe('built-in conversation node Definitions', () => { // behavior of both required Definition members anyway. const match = (seq: number, type: string, data: unknown) => ({ event: { seq, time: seq * 1_000, type, data }, - view: undefined, role: 'start', location: undefined, }) as unknown as Parameters[1] diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index f8f050d682..d4e4022e1c 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -168,22 +168,24 @@ describe('render branch tails', () => { expect(view.getByText('该调用不在当前窗口内')).toBeTruthy() }) - it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => { + it('DetailsPanel passes the existing parentCallId through to the Tool details seat', () => { localStorage.clear() const session = sessionSnapshot() const longText = 'x'.repeat(1_000) const runningCalls: readonly RunningToolCall[] = [{ callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1, - time: 7_000, callView: null, subCalls: [{ + time: 7_000, subCalls: [{ kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1', + parentCallId: 'p1', call: { name: 'run_code', argsRaw: '{"code":"return 1"}' }, callTime: 8_000, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, subCalls: [{ kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1', + parentCallId: 'p1:code:1', call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, callTime: 8_500, - content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null, + content: [{ type: 'text', text: longText }], isError: false, subCalls: [], }], }], @@ -224,13 +226,14 @@ describe('render branch tails', () => { t={t} />, ) - // Chat resolves the selected sub-call and hands its complete - // frozen block to the Tool-owned details seat. + // Chat resolves the selected sub-call and keeps its Code Dispatch parent + // identity on the block handed to the Tool-owned details seat. expect(view.getByText('read')).toBeTruthy() expect(view.getByTestId('tool-details-seat')).toBeTruthy() expect(owners).toHaveLength(1) expect(owners[0]?.block).toMatchObject({ callId: 'p1:code:1:code:1', + parentCallId: 'p1:code:1', call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' }, content: [{ type: 'text', text: longText }], }) diff --git a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts index 28cfda51be..d53c34e69c 100644 --- a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts @@ -21,7 +21,7 @@ const settle = (seq: number, parentCallId: string, subCallId: string): SessionEv const root = (callId: string): RunningToolCall => ({ callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1, - time: 1_700_000_000_000, callView: null, subCalls: [], + time: 1_700_000_000_000, subCalls: [], }) describe('ToolCallTree', () => { @@ -42,8 +42,8 @@ describe('ToolCallTree', () => { expect(tree.projectRunningCalls([root('a')])).toMatchObject([{ callId: 'a', subCalls: [{ - callId: 'b', - subCalls: [{ callId: 'c', subCalls: [] }], + callId: 'b', parentCallId: 'a', + subCalls: [{ callId: 'c', parentCallId: 'b', subCalls: [] }], }], }]) }) diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index 01a019802d..d8954bb8cf 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -1,14 +1,12 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types' /* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents -- * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program; * installed business packages supply their concrete keys in consuming Client programs. */ -/** One raw log event plus its optional envelope-level presentation view. */ +/** One raw Session log event consumed by Conversation assembly. */ export interface ConversationEventInput { readonly event: SessionEvent - readonly view?: SessionToolView } /** Definition-local identity and lifecycle role extracted from one event. */ diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index a03ac77ce7..0b43e1fa55 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -8,9 +8,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' -import type { - ToolCallView, ToolResultView, -} from '@deepseek-ai/dsh-api-remotes/client' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' export type { TodoItem } @@ -161,6 +158,8 @@ export interface ToolResultNode { /** Unix epoch ms from the tool/result session event. */ time: number callId: string + /** Parent Tool call for a Code Dispatch result; absent on a root Session result. */ + parentCallId?: string /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ call: { name: string; argsRaw: string } | null /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ @@ -169,10 +168,6 @@ export interface ToolResultNode { isError: boolean error?: { name: string; code: string } meta?: unknown - /** Host-computed render intent from the paired tool/call's wire view; null = generic JSON card (documented default). */ - callView: ToolCallView | null - /** Host-computed render intent from this tool/result's wire view; null = same default. */ - resultView: ToolResultView | null /** Child calls owned by this call, in dispatch order. */ subCalls: readonly ToolCallBlock[] } @@ -268,14 +263,14 @@ export type ConversationNode = /** In-flight tool card material: tool/call seen, tool/result not yet. */ export interface RunningToolCall { callId: string + /** Parent Tool call for a Code Dispatch start; absent on a root Session call. */ + parentCallId?: string name: string argsRaw: string turn: number step: number /** Unix epoch ms when the tool/call event was logged. */ time: number - /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */ - callView: ToolCallView | null /** Child calls owned by this call, in dispatch order. */ subCalls: readonly ToolCallBlock[] } diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 4fedf47572..a51b531bfe 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -189,7 +189,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore /** * Add one contiguous live tail event without scanning existing Contexts. - * @param input - appended Event and optional wire view. + * @param input - appended Session event. * @returns highest requested publication cadence. */ append(input: ConversationEventInput): ConversationPublication { diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index cf0c4380b7..58ea1bf79d 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -128,10 +128,7 @@ class BoundConversation implements ConversationBinding { } function conversationInput(entry: SessionEventEntry): ConversationEventInput { - return { - event: entry.event as unknown as SessionEvent, - ...(entry.view === undefined ? {} : { view: entry.view }), - } + return { event: entry.event as unknown as SessionEvent } } interface BindingRecord { diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index 432a45e9aa..daac0ed0de 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -24,8 +24,6 @@ function settled(over: Partial = {}): ToolResultNode { callTime: 2_000, content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }], isError: false, - callView: null, - resultView: null, subCalls: [], ...over, } @@ -33,7 +31,7 @@ function settled(over: Partial = {}): ToolResultNode { function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall { return { - callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, subCalls: [], + callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [], } } @@ -42,6 +40,7 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro callId: block.callId, toolName: 'skill', block, + openFile: vi.fn(), inspect, t, diff --git a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx index ec908af84a..42ddd41824 100644 --- a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx @@ -13,8 +13,8 @@ import { webCardModel } from './models/web-card-model.ts' import css from './ToolDetails.module.css' /** - * Render the selected Tool call's structured output when its presentation - * intent is known, otherwise preserve the flattened result text. + * Render the selected Tool call's structured output when its raw fields form a + * supported root card, otherwise preserve the flattened result text. * @param props - selected call slice, workspace root, host home, and locale seat. * @returns the details output body. */ diff --git a/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts b/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts index ec19f1cce0..eed7826594 100644 --- a/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts @@ -1,14 +1,7 @@ -/** - * Pure derivation of the diff-card props from a frozen call slice: the - * `card:'diff'` render intent the write/edit tools declare arrives on the - * snapshot as `callView`/`resultView`, and this is the one place that turns - * that pair into what {@link DiffBlock} draws. Both conversation render sites - * (the chat tool row's expanded body and the details panel's Output section) - * call this, so the hunks they show are derived once. - * @module - */ +/** Pure diff-card derivation from raw write/edit calls and result metadata. @module */ import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallBlock } from './tool-call-model.ts' +import { parsedToolCall, validEscalationFields } from './raw-tool-call.ts' /** * Diff-body lines the chat row shows before collapsing the middle — half the @@ -36,13 +29,8 @@ export interface DiffCardModel { } /** - * Narrow a wire `card:'diff'` view's `diffs` to well-formed hunks. The event - * view crosses the wire and `toolEventViewSchema` validates only the `card` - * string, so a version mismatch or an anomalous plugin can deliver a `diff` card - * whose `diffs` is absent, not an array, or carries malformed hunks. Returning - * null for any of those routes the block to the generic path instead of letting - * DiffBlock's `for...of`/`split` throw and crash the row or the details panel. - * @param diffs - the view's `diffs` field, unverified. + * Narrow opaque result metadata's `diffs` to well-formed hunks. + * @param diffs - the metadata field to validate. * @returns the validated hunks, or null when the payload is not usable. */ function narrowDiffs(diffs: unknown): DiffHunk[] | null { @@ -59,39 +47,51 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null { return out } +type IntendedDiff = { tool: 'write' | 'edit'; diff: DiffHunk } + +function intendedDiff(block: ToolCallBlock): IntendedDiff | null { + const parsed = parsedToolCall(block) + if (parsed === null) return null + const { file_path: path } = parsed.args + if (typeof path !== 'string' || path.trim() === '') return null + if (!validEscalationFields(parsed.args)) return null + if (parsed.name === 'write') { + const { content } = parsed.args + return typeof content === 'string' + ? { tool: 'write', diff: { path, oldText: null, newText: content } } + : null + } + if (parsed.name !== 'edit') return null + const { old_string: oldText, new_string: newText, replace_all: replaceAll } = parsed.args + if (typeof oldText !== 'string' || typeof newText !== 'string') return null + if (replaceAll !== undefined && typeof replaceAll !== 'boolean') return null + return { tool: 'edit', diff: { path, oldText: oldText || null, newText } } +} + +function appliedDiffs(meta: unknown): DiffHunk[] | 'empty' | null { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return null + const diffs = (meta as Record).diffs + if (!Array.isArray(diffs)) return null + if (diffs.length === 0) return 'empty' + return narrowDiffs(diffs) +} + /** - * Derive the diff-card props for a tool call, or null when this call is not a - * diff card and belongs on the generic path. - * - * The result side is authoritative once the call settles: the write/edit tools - * return the applied contextual hunks there (an edit's real before/after, a - * create's whole-file diff), which replace the call-time diff derived from the - * arguments alone. While the call is still running only the call side exists, - * so a running write/edit shows its intended change. Null is the documented - * generic-card default and covers every non-diff card — including a `card` - * value this UI version does not know, which arrives over the wire and cannot - * be trusted to be one of the compiled variants — and a settled call whose - * result view is generic (how write/edit keep their execution errors on the - * generic path). - * - * This derivation consumes only `diffs`; the render intent's `title` field is - * deliberately dropped. The row supplies its own title (`Edit`/`Write · path` - * from the args), which outranks the view's `title`. A tool that names its own - * diff header therefore does not surface that text on the Web row. - * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * Derive intended running or applied settled diffs for a root write/edit call. + * A successful write with valid empty metadata uses its argument-derived + * whole-file diff, matching create and identical-overwrite presentation. + * @param block - running or settled Tool block. * @returns the diff-card props, or null for the generic path. */ export function diffCardModel(block: ToolCallBlock): DiffCardModel | null { - if (!('kind' in block)) { - // Running: the call view may carry the intended diff; the result is absent. - const call = block.callView?.card === 'diff' ? block.callView : null - const diffs = call === null ? null : narrowDiffs(call.diffs) - return diffs === null ? null : { card: { diffs } } + if (block.parentCallId !== undefined) return null + const intended = intendedDiff(block) + if (intended === null) return null + if (!('kind' in block)) return { card: { diffs: [intended.diff] } } + if (block.isError) return null + const applied = appliedDiffs(block.meta) + if (applied === null || applied === 'empty') { + return intended.tool === 'write' ? { card: { diffs: [intended.diff] } } : null } - // Settled: the result view's applied hunks replace the call-time diff. A - // window that dropped the call head leaves only the result, which still - // renders — the result view carries the whole change. - const result = block.resultView?.card === 'diff' ? block.resultView : null - const diffs = result === null ? null : narrowDiffs(result.diffs) - return diffs === null ? null : { card: { diffs } } + return { card: { diffs: applied } } } diff --git a/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts b/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts new file mode 100644 index 0000000000..8d3dad773a --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts @@ -0,0 +1,64 @@ +/** Shared narrowing for raw Tool call and result fields consumed by card models. */ +import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' + +/** A parsed, in-window Tool call whose arguments are a JSON object. */ +export interface ParsedToolCall { + name: string + args: Record +} + +const parsedCalls = new WeakMap() + +/** + * Parse the call head paired with one immutable Tool block. + * @param block - running or settled Tool block. + * @returns the Tool name and object arguments, or null when the call head or valid JSON object is unavailable. + */ +export function parsedToolCall(block: ToolCallBlock): ParsedToolCall | null { + const cached = parsedCalls.get(block) + if (cached !== undefined || parsedCalls.has(block)) return cached ?? null + const call = 'kind' in block ? block.call : block + if (call === null) { + parsedCalls.set(block, null) + return null + } + let value: unknown + try { + value = JSON.parse(call.argsRaw) + } catch { + parsedCalls.set(block, null) + return null + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + parsedCalls.set(block, null) + return null + } + const parsed = { name: call.name, args: value as Record } + parsedCalls.set(block, parsed) + return parsed +} + +/** + * Read the exact single text block consumed by first-party card derivations. + * @param block - settled Tool result. + * @returns its text, or undefined for any other content layout. + */ +export function singleResultText(block: ToolResultNode): string | undefined { + if (block.content.length !== 1) return undefined + const only = block.content[0] + return only?.type === 'text' ? only.text : undefined +} + +/** + * Validate the optional escalation pair shared by first-party shell and file + * mutation tools. + * @param args - parsed open-root Tool arguments. + * @returns whether the declared escalation fields form a valid pair. + */ +export function validEscalationFields(args: Record): boolean { + const permission = args.sandbox_permissions + const justification = args.justification + if (permission === undefined && justification === undefined) return true + if (permission !== 'workspace-write' && permission !== 'danger-full-access') return false + return typeof justification === 'string' && justification.trim() !== '' +} diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts index b00915a433..a5a8fffdfe 100644 --- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -1,21 +1,8 @@ -/** - * Pure derivation of the read-card props from a frozen call slice: the - * `card:'read'` render intent the read tool declares arrives on the snapshot as - * the settled result node's `resultView`, and this is the one place that turns - * it into what {@link ReadBlock} draws. Both conversation render sites (the chat - * tool row's resident body and the details panel's Output section) call this, so - * the path, lines, total, and language they show are derived once. - * - * The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)): - * a call carries no file content until `execute` returns, so the pending call - * stays a generic card (`kind: 'read'`). A running read therefore has no read - * card, and this returns null for it — the row keeps its args-derived summary - * until the result arrives. - * @module - */ +/** Pure read-card derivation from raw result content and metadata. @module */ import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' +import { parsedToolCall, singleResultText } from './raw-tool-call.ts' /** * Content lines the chat row's resident read body shows before collapsing the @@ -36,43 +23,75 @@ export const CHAT_READ_MAX_LINES = 8 */ export type ReadCardModel = Pick +interface ReadMeta { + path: string + offset: number + lines: ReadBlockLine[] + totalLines: number + lang?: string +} + +function validReadCall(block: ToolCallBlock): boolean { + const call = parsedToolCall(block) + if (call?.name !== 'read') return false + const { file_path: path, offset, limit } = call.args + if (typeof path !== 'string' || path.trim() === '') return false + if (offset !== undefined && (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 1)) return false + if (limit !== undefined && (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1)) return false + return true +} + +function readMeta(meta: unknown): ReadMeta | null { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return null + const { path, offset, lines, totalLines, lang } = meta as Record + if (typeof path !== 'string' || typeof offset !== 'number' || !Number.isInteger(offset) || offset < 1) return null + if (typeof totalLines !== 'number' || !Number.isInteger(totalLines) || totalLines < 0 || !Array.isArray(lines)) return null + if (lang !== undefined && typeof lang !== 'string') return null + const narrowed: ReadBlockLine[] = [] + let previous = offset - 1 + for (const line of lines) { + if (typeof line !== 'object' || line === null || Array.isArray(line)) return null + const { number, text } = line as Record + if (typeof number !== 'number' || !Number.isInteger(number) || number < 1 || number <= previous) return null + if (number > totalLines || typeof text !== 'string') return null + previous = number + narrowed.push({ number, text }) + } + return { + path, + offset, + lines: narrowed, + totalLines, + ...lang === undefined ? {} : { lang }, + } +} + /** - * Derive the read-card props for a tool call, or null when this call is not a - * read card and belongs on the generic path. - * - * The read card is result-side only, so only a settled call whose result view - * declares `card:'read'` produces one. Every other case is null — the - * documented generic-card default: - * - * - A running call: it has no result view yet, and a read carries no content at - * call time. - * - A settled call whose result view is not a read card — including a `card` - * value this UI version does not know, which arrives over the wire and cannot - * be trusted to be one of the compiled variants, and the read tool's own - * generic fallback for an error result or a non-envelope body. - * - * The label is the read view's `title` when the tool supplied one (the - * presentation contract's replacement-title rule), otherwise the file path - * shortened the same way the row summary is: workspace-relative first, then - * POSIX `~` for a leftover host-home path. - * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * Derive a settled root read card after validating its persisted metadata and + * model-facing read envelope. + * @param block - running or settled Tool block. * @param sessionCwd - the session workspace root; a workspace-rooted absolute * path label displays relative to it. Absent leaves the path as authored. * @param home - host account home; a leftover POSIX home path displays as `~`. * @returns the read-card props, or null for the generic path. */ -export function readCardModel(block: ToolCallBlock, sessionCwd?: string, home?: string): ReadCardModel | null { - // Running has no result view; a read carries no content until execute returns. - if (!('kind' in block)) return null - const result = block.resultView?.card === 'read' ? block.resultView : null - if (result === null) return null - // Lines arrive frozen off the snapshot; copy into the primitive's own line - // shape so the card never holds a reference into the runtime's cache. - const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text })) +export function readCardModel( + block: ToolCallBlock, + sessionCwd?: string, + home?: string, +): ReadCardModel | null { + if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null + if (!validReadCall(block)) return null + const meta = readMeta(block.meta) + if (meta === null) return null + const text = singleResultText(block) + if (text === undefined) return null + const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1] + if (body === undefined) return null return { - label: result.title ?? abbreviateHomePath(relativizeToCwd(result.path, sessionCwd), home), - lines, - totalLines: result.totalLines, - lang: result.lang, + label: abbreviateHomePath(relativizeToCwd(meta.path, sessionCwd), home), + lines: meta.lines, + totalLines: meta.totalLines, + lang: meta.lang, } } diff --git a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts index 4536833215..cc4d25b6b5 100644 --- a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts @@ -1,109 +1,71 @@ -/** - * Pure derivation of the search-card props from a frozen call slice: the - * `card:'search'` render intent the `grep` and `glob` tools declare arrives on - * the snapshot as `resultView`, and this is the one place that turns it into - * what {@link SearchBlock} draws. Both conversation render sites (the chat tool - * row's resident body and the details panel's Output section) call this, so the - * grouped matches or the path list they show are derived once. - * - * The search card is result-time only: a search call has no matches or paths - * before `execute`, so its pending state stays a `GenericCallView` - * ({@link module:@deepseek-ai/dsh-tools/src/presentation}). This derivation - * therefore reads only `resultView` and returns null for a still-running call, - * unlike the terminal card whose call view carries the command before - * execution. - * - * A capped result also carries a recovery locator (grep/glob's `Full … stored - * at …` footer) in the raw `tool/result` content, not in the structured - * matches/paths the view carries. Since both render sites replace that raw - * result with the card, this derivation surfaces the block's own result text as - * {@link SearchCardModel.recovery} so the one path to the dropped rows is not - * lost. - * @module - */ +/** Pure search-card derivation from raw grep/glob result metadata. @module */ import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallBlock } from './tool-call-model.ts' +import { parsedToolCall } from './raw-tool-call.ts' -/** - * Distributive `Omit`: a plain `Omit` keeps only the keys common to - * both members, which would drop the `files`/`paths` discriminated fields. - * Distributing over the naked type parameter `T` preserves each shape. - */ type DistributiveOmit = T extends unknown ? Omit : never /** The {@link SearchBlockProps} union minus each render site's own fields. */ type SearchBlockModelProps = DistributiveOmit -/** - * Result rows the chat row's resident search body shows before collapsing the - * middle — half the primitive's own default, which the details panel keeps. A - * chat row is a summary surface inside the message flow: the flow must stay - * scannable across many calls, while the details panel is the single-call - * reading surface. A design constant of this UI's row geometry, not a - * deployment choice, so it is fixed here rather than a plugin Config field. - */ +/** Result rows retained in a Chat card before its middle collapses. */ export const CHAT_SEARCH_MAX_LINES = 8 -/** - * The {@link SearchBlock} props this derivation owns. Held as a nested object - * (`card`) so a render site spreads exactly the primitive's own surface and can - * never leak a neighbouring field into it. `maxLines`/`className` belong to each - * render site. - */ +/** Search-card props plus an optional locator for a capped full result. */ export interface SearchCardModel { - /** - * The props {@link SearchBlock} draws, minus each render site's own - * `maxLines`/`className`. - */ + /** Props consumed by {@link SearchBlock}. */ card: SearchBlockModelProps - /** - * The result view's replacement title, which the presentation contract lets a - * search tool set at settle time. Absent when the presenter supplied none; a - * row then keeps its args-derived summary. - */ - title: string | undefined - /** - * The raw `tool/result` text, flattened, surfaced only when the search was - * capped. The card renders the retained matches or paths, but the recovery - * locator a capped result carries — grep/glob's `Full … stored at: ` - * footer, the one way to reach the rows the cap dropped — lives only in the raw - * result text, which the card replaces. A UI that shows the card would - * otherwise lose it. Absent when the result was not capped (the card holds - * every result) or the block carries no text. - */ + /** Raw result text containing the full-result locator for a capped search. */ recovery: string | undefined } -/** - * Whether every file group in a matches view is structurally valid: the wire - * frame carries `shape` and `card` as strings the host schema checks, but not the - * grouped `files` fields, so a version mismatch or loose producer could deliver - * `shape: 'matches'` with a missing or malformed `files`. Rendering that would - * crash {@link SearchBlock} at `.reduce`/`.map`; invalid fields select the - * generic path instead. - * @param files - the candidate `files` field off the untrusted result view. - * @returns whether `files` is a valid {@link SearchFileGroup} array. - */ -function isValidFiles(files: unknown): files is SearchFileGroup[] { - return Array.isArray(files) && files.every(file => - typeof file === 'object' && file !== null - && typeof (file as { path?: unknown }).path === 'string' - && Array.isArray((file as { matches?: unknown }).matches) - && (file as { matches: unknown[] }).matches.every(match => - typeof match === 'object' && match !== null - && typeof (match as { lineNumber?: unknown }).lineNumber === 'number' - && typeof (match as { line?: unknown }).line === 'string')) +function validSearchCall(block: ToolCallBlock): 'grep' | 'glob' | null { + const call = parsedToolCall(block) + if (call === null) return null + const { pattern, path } = call.args + if (typeof pattern !== 'string') return null + if (call.name === 'grep' && pattern === '') return null + if (call.name === 'glob' && pattern.trim() === '') return null + if (call.name !== 'grep' && call.name !== 'glob') return null + if (path !== undefined && (typeof path !== 'string' || path.trim() === '')) return null + if (call.name === 'grep') { + const { include } = call.args + if (include !== undefined && (typeof include !== 'string' || !validInclude(include))) return null + } + return call.name +} + +function validInclude(include: string): boolean { + if (include.trim() === '' || include.startsWith('!')) return false + let braceDepth = 0 + for (const character of include) { + if (character === '{') braceDepth += 1 + else if (character === '}') braceDepth = Math.max(0, braceDepth - 1) + else if (character === ',' && braceDepth === 0) return false + } + return true +} + +function searchFiles(value: unknown): SearchFileGroup[] | null { + if (!Array.isArray(value)) return null + const files: SearchFileGroup[] = [] + for (const file of value) { + if (typeof file !== 'object' || file === null || Array.isArray(file)) return null + const { path, matches } = file as Record + if (typeof path !== 'string' || !Array.isArray(matches)) return null + const narrowed: { lineNumber: number; line: string }[] = [] + for (const match of matches) { + if (typeof match !== 'object' || match === null || Array.isArray(match)) return null + const { lineNumber, line } = match as Record + if (typeof lineNumber !== 'number' || !Number.isInteger(lineNumber) || lineNumber < 1) return null + if (typeof line !== 'string') return null + narrowed.push({ lineNumber, line }) + } + files.push({ path, matches: narrowed }) + } + return files } -/** - * Flatten a settled tool result's content blocks to their text, joined by - * newlines. The search view carries no result text — a UI without a card falls - * back to the raw `tool/result` content — so the truncation recovery footer is - * read from the block's own content here. Non-text blocks (a search result - * carries none) are skipped. - * @param content - the result node's content blocks. - * @returns the joined text, or undefined when empty. - */ function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined { const text = content .filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string') @@ -113,48 +75,26 @@ function flattenContent(content: readonly { type: string; text?: string }[]): st } /** - * Derive the search-card props for a tool call, or null when this call is not a - * search card and belongs on the generic path. - * - * Only the result side matters: the search card carries no call-time state, so - * a still-running call (no result view) is null, as is a settled call whose - * result view is not a search card — including a `card` value this UI version - * does not know, which arrives over the wire and cannot be trusted to be one of - * the compiled variants, a `card: 'search'` view whose `shape` is neither - * `matches` nor `paths` (equally untrusted wire data), and a generic result a - * `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps - * the generic path). - * @param block - RunningToolCall or ToolResultNode off the snapshot caches. - * @returns the search-card props, or null for the generic path. + * Derive a settled root grep/glob card from persisted metadata. + * @param block - running or settled Tool block. + * @returns search-card props, or null for the generic path. */ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { - // Running: no result view exists yet, and a search card is result-only. - if (!('kind' in block)) return null - const result = block.resultView?.card === 'search' ? block.resultView : null - if (result === null) return null - const common = { truncated: result.truncated, total: result.total } - // The recovery footer only matters when the tool capped the result: an - // uncapped card holds every match/path, so the raw text adds nothing the card - // does not already show. When capped, the raw result's `Full … stored at …` - // locator is the only way to retrieve the omitted rows, so include it. - const recovery = result.truncated ? flattenContent(block.content) : undefined - if (result.shape === 'matches') { - // `files` rides the untrusted wire frame: the host schema checks `card`/`shape` - // strings but not the grouped `files` fields, so validate them before - // SearchBlock, which would crash on a missing or malformed `files`. - // Invalid fields select the generic view. - if (!isValidFiles(result.files)) return null - return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } } + if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null + const tool = validSearchCall(block) + if (tool === null) return null + if (typeof block.meta !== 'object' || block.meta === null || Array.isArray(block.meta)) return null + const meta = block.meta as Record + if (typeof meta.truncated !== 'boolean') return null + if (typeof meta.total !== 'number' || !Number.isInteger(meta.total) || meta.total < 0) return null + const common = { truncated: meta.truncated, total: meta.total } + const recovery = meta.truncated ? flattenContent(block.content) : undefined + if (tool === 'grep') { + if (meta.shape !== 'matches') return null + const files = searchFiles(meta.files) + return files === null ? null : { recovery, card: { kind: 'matches', files, ...common } } } - // `shape` rides the same untrusted wire frame as `card`, so a version mismatch - // or a loose protocol producer could deliver a `card: 'search'` subtype this - // client does not compile. Guard the paths shape explicitly: an unknown shape - // falls to the generic path rather than being rendered as a paths card, which - // would leave SearchBlock calling `.length`/`.map` on an absent `paths`. - // oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive. - if (result.shape !== 'paths') return null - // `paths` is likewise unchecked by the wire schema; a known shape with a - // missing/malformed array would crash the paths card at `.map`. - if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null - return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } } + if (meta.shape !== 'paths' || !Array.isArray(meta.paths)) return null + if (!meta.paths.every((path): path is string => typeof path === 'string')) return null + return { recovery, card: { kind: 'paths', paths: [...meta.paths], ...common } } } diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index b8b25fa8bf..a34a56dfc7 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -1,17 +1,9 @@ -/** - * Pure derivation of the terminal-card props from a frozen call slice: the - * `card:'terminal'` render intent the shell tools declare arrives on the - * snapshot as `callView`/`resultView`, and this is the one place that turns - * that pair into what {@link TerminalBlock} draws. Both conversation render - * sites (the chat tool row's expanded body and the details panel's Output - * section) call this, so the command, cwd, output and exit status they show - * are derived once. - * @module - */ +/** Pure terminal-card derivation from raw Tool call and result fields. @module */ import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import type { ToolCallBlock } from './tool-call-model.ts' +import { parsedToolCall, singleResultText, validEscalationFields } from './raw-tool-call.ts' /** * Build the TerminalBlock display copy from the conversation locale seat — @@ -40,9 +32,8 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo /** * The {@link TerminalBlock} props this derivation owns. Picked off the - * primitive's props so the two stay in step; `home` is absent because the web - * client has no home path for the session host (a cwd renders as its last - * path segment), and `maxLines`/`className` belong to each render site. + * primitive's props so the two stay in step; `maxLines`/`className` belong to + * each render site. */ export interface TerminalCardModel { /** @@ -52,10 +43,8 @@ export interface TerminalCardModel { */ card: Pick /** - * The call view's model-authored description, which the contract defines as - * rendering ABOVE the card (the card itself has no description slot). Absent - * when the presenter supplied none, or when the window dropped the call side; - * a row then keeps its args-derived summary. + * The model-authored call description rendered above the card. Absent for + * persistent shells, whose parameter set has no description. */ description: string | undefined } @@ -75,21 +64,18 @@ export function terminalFailed(model: TerminalCardModel): boolean { } /** - * Resolve a terminal view's working directory the way the render-intent - * contract assigns to the UI bridge: an absolute path is used as-is, a relative - * one joins under the session workspace, and an omitted one IS the session - * workspace. A pure presenter cannot see the session cwd, which is why this - * resolution belongs here rather than in the tool. Without a session cwd there - * is nothing to resolve against, so a relative path stays as authored and an - * omitted one stays absent (the prompt row then draws a bare `$`). - * @param viewCwd - the cwd the terminal call view carries, if any. + * Resolve a shell call's workdir for display: an absolute path is used as-is, + * a relative one joins under the session workspace, and an omitted one is the + * session workspace. Without a session cwd, a relative path stays as authored + * and an omitted one stays absent. + * @param workdir - the raw call's workdir, if any. * @param sessionCwd - the session workspace root, if the caller knows it. * @returns the working directory for the prompt label, or undefined. */ -function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined { - if (viewCwd === undefined || viewCwd === '') return sessionCwd - if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd) - return normalizeSegments(resolveWorkspacePath(sessionCwd, viewCwd)) +function resolveTerminalCwd(workdir: string | undefined, sessionCwd: string | undefined): string | undefined { + if (workdir === undefined || workdir === '') return sessionCwd + if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(workdir) + return normalizeSegments(resolveWorkspacePath(sessionCwd, workdir)) } /** @@ -153,40 +139,94 @@ function collapse(body: string, rooted: boolean, separator = '/'): string { return kept.join(separator) } +interface ShellCall { + command: string + description: string | undefined + workdir: string | undefined + persistent: boolean + background: boolean +} + +function shellCall(name: string, args: Record): ShellCall | null { + if (name !== 'bash' && name !== 'pwsh') return null + const { command, description, timeoutMs, workdir, run_in_background: background } = args + if (typeof command !== 'string' || command.trim() === '') return null + if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) return null + if (workdir !== undefined && typeof workdir !== 'string') return null + if (background !== undefined && typeof background !== 'boolean') return null + if (!validEscalationFields(args)) return null + if (description === undefined) { + // Persistent shell providers consume only `command`; parameter roots are + // open, so unrelated fields do not change their running-card behavior. + return { command, description: undefined, workdir: undefined, persistent: true, background: false } + } + if (typeof description !== 'string' || description.trim() === '') return null + return { + command, + description, + workdir, + persistent: false, + background: background === true, + } +} + +interface TerminalSendCall { + command: string + description: string + background: boolean +} + +function terminalSendCall(name: string, args: Record): TerminalSendCall | null { + if (name !== 'terminal_send') return null + const { sessionId, text, submit, run_in_background: background } = args + if (typeof sessionId !== 'string' || sessionId === '' || typeof text !== 'string') return null + if (submit !== undefined && typeof submit !== 'boolean') return null + if (background !== undefined && typeof background !== 'boolean') return null + return { + command: text || '(send input)', + description: `Terminal ${sessionId}`, + background: background === true, + } +} + +function parseExitStatus(text: string): { output: string; exitCode?: number; signal?: string } { + const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) + if (signal?.[1] !== undefined) return { output: text.slice(0, signal.index), signal: signal[1] } + const exit = /\n\[exit code: (\d+)\]$/.exec(text) + if (exit?.[1] !== undefined) return { output: text.slice(0, exit.index), exitCode: Number(exit[1]) } + return { output: text, exitCode: 0 } +} + /** - * Derive the terminal-card props for a tool call, or null when this call is - * not a terminal card and belongs on the generic path. - * - * The call side supplies the command and its working directory; the result - * side supplies the captured output and exit status. Three cases produce - * null, all of them the documented generic-card default: - * - * - Neither side declares `card:'terminal'` — including a `card` value this - * UI version does not know, which arrives over the wire and therefore - * cannot be trusted to be one of the compiled variants. - * - A settled call whose result view is not a terminal card: the result - * presentation decides how the settled call renders, and the bash tool - * returns a generic fenced card for an execution error or a background - * start, whose text and error styling the generic path preserves. - * - * Window truncation can drop the call head from a settled `ToolResultNode`, - * leaving a terminal result with no call side. That still renders: the command - * falls back to the result view's replacement title, then to an empty command (the prompt line - * draws bare), and the prompt shows no cwd. - * @param block - RunningToolCall or ToolResultNode off the snapshot caches. - * @param sessionCwd - the session workspace root, which resolves an omitted or - * relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved. + * Derive terminal props for supported root shell and terminal-send calls. + * Standard shell results parse their final status marker; persistent shell + * results, background calls, errors, malformed input, or child dispatches use + * the generic path. + * @param block - running or settled Tool block. + * @param sessionCwd - session workspace root used to resolve workdir. * @returns the terminal-card props, or null for the generic path. */ -export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null { - const call = block.callView?.card === 'terminal' ? block.callView : null +export function terminalCardModel( + block: ToolCallBlock, + sessionCwd?: string, +): TerminalCardModel | null { + if (block.parentCallId !== undefined) return null + const parsed = parsedToolCall(block) + if (parsed === null) return null + const shell = shellCall(parsed.name, parsed.args) + const send = terminalSendCall(parsed.name, parsed.args) + if (shell === null && send === null) return null + if (shell?.background === true || send?.background === true) return null + + const command = shell?.command ?? send?.command ?? '' + const description = shell?.description ?? send?.description + const cwd = resolveTerminalCwd(shell?.workdir, sessionCwd) if (!('kind' in block)) { - // Running: the call view exists, the result view does not yet. - return call === null ? null : { - description: call.description, + return { + description, card: { - command: call.title, - cwd: resolveTerminalCwd(call.cwd, sessionCwd), + command, + cwd, output: undefined, exitCode: undefined, signal: undefined, @@ -194,24 +234,18 @@ export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): Te }, } } - const result = block.resultView?.card === 'terminal' ? block.resultView : null - if (result === null) return null + if (block.isError || shell?.persistent === true) return null + const output = singleResultText(block) + if (output === undefined) return null + const status = shell === null ? { output } : parseExitStatus(output) return { - description: call?.description, + description, card: { - // The result's title REPLACES the pending one when the tool supplies it - // (the presentation contract's replacement-title rule); the call title is - // what a result without one keeps. - command: result.title ?? call?.title ?? '', - // Only a PRESENT call view can mean "omitted the cwd, so use the - // workspace". When the window dropped the call head there is no cwd - // anywhere — the result view carries none — and the original call may - // well have used an explicit workdir, so the prompt draws a bare `$` - // rather than naming a directory this card cannot know. - cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd), - output: result.output, - exitCode: result.exitCode, - signal: result.signal, + command, + cwd, + output: status.output, + exitCode: status.exitCode, + signal: status.signal, running: false, }, } diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 8af6fb5335..a95861ea01 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -2,9 +2,8 @@ * Pure row-model derivation for tool summary rows: variant classification, * one-line summary, expanded-body text, and flattened result output from the * frozen call slice. Input material comes from the call ARGUMENTS; output and - * error material from the settled result node. A call whose render intent is - * a terminal card gets its expanded body from the views instead, through - * `terminalCardModel` in terminal-card-model.ts. + * error material from the settled result node. A supported terminal call gets + * its expanded body from `terminalCardModel` instead. */ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer diff --git a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts index 8239861953..27e416f99d 100644 --- a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts @@ -1,84 +1,82 @@ -/** - * Pure derivation of the web-card props from a frozen call slice: the - * `card:'web'` render intent the `web_search`/`web_fetch` tools declare at - * result time arrives on the snapshot as `resultView`, and this is the one - * place that turns it into what {@link WebBlock} draws. Both conversation - * render sites (the chat tool row's resident/expanded body and the details - * panel's Output section) call this, so the sources and fetch summary they - * show are derived once. - * - * The web card is result-only by contract: those tools keep a generic pending - * call view, so there is nothing to derive while the call is still running and - * a running call always takes the generic path. - * @module - */ +/** Pure web-card derivation from raw web result metadata. @module */ import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallBlock } from './tool-call-model.ts' +import { parsedToolCall } from './raw-tool-call.ts' -/** - * Derive the web-card props for a tool call, or null when this call is not a - * web card and belongs on the generic path. - * - * The result side supplies the whole card: the sources and answer for a - * `search`, the URL and status for a `fetch`. Cases producing null, all of - * them the documented generic-card default: - * - * - A running call (no `resultView` yet): the web tools keep a generic pending - * card, so nothing web-shaped exists until the call settles. - * - A settled call whose result view is not a web card — including a `card` - * value this UI version does not know, which arrives over the wire and so - * cannot be trusted to be one of the compiled variants, and a generic result - * view (a web tool's error path returns the generic card, whose text the - * generic path preserves). - * - A web card whose `kind` this UI version does not know (a newer host's - * value): the wire cannot be trusted to be `search` or `fetch`, so it takes - * the generic path rather than rendering as a malformed fetch. - * @param block - RunningToolCall or ToolResultNode off the snapshot caches. - * @returns the web-card props, or null for the generic path. - */ type DistributiveOmit = T extends unknown ? Omit : never /** Web-card data owned by the presenter; render sites add localized labels and classes. */ export type WebCardModelProps = DistributiveOmit -/** - * Derive locale-independent web-card data from a frozen tool-call slice. - * @param block - Running or settled tool call from the conversation snapshot. - * @returns Web-card data, or null when the generic presenter owns the call. - */ -export function webCardModel(block: ToolCallBlock): WebCardModelProps | null { - // Running calls have no result view; the web card is result-only. - if (!('kind' in block)) return null - const result = block.resultView - if (result?.card !== 'web') return null - if (result.kind === 'search') { - return { - kind: 'search', - answer: result.answer, - sources: result.sources.map(source => ({ - url: source.url, - title: source.title, - snippet: source.snippet, - publishedAt: source.publishedAt, - })), - truncated: result.truncated, - } +function validWebCall(block: ToolCallBlock): 'web_search' | 'web_fetch' | null { + const call = parsedToolCall(block) + if (call === null) return null + if (call.name === 'web_search') { + const { queries } = call.args + if (!Array.isArray(queries) || queries.length === 0) return null + return queries.every(query => typeof query === 'string' && query.trim() !== '') ? call.name : null } - // Discriminate `fetch` explicitly rather than treating it as the else of - // `search`: a `kind` this UI version does not know arrives over the wire from - // a newer host, and reading it as a fetch would draw an empty URL and - // `HTTP undefined`. It takes the generic path, the same wire-boundary default - // an unknown `card` tag takes above. The static union narrows `kind` to - // `'fetch'` here, but the runtime value is off the wire, so the guard and its - // null fallthrough are load-bearing despite the type. - // oxlint-disable-next-line typescript/no-unnecessary-condition - if (result.kind === 'fetch') { - return { - kind: 'fetch', - url: result.url, - statusCode: result.statusCode, - truncated: result.truncated, - } + if (call.name === 'web_fetch') { + const { url } = call.args + return typeof url === 'string' && url.trim() !== '' ? call.name : null } return null } + +interface WebSource { + url: string + title?: string + snippet?: string + publishedAt?: string +} + +function webSources(value: unknown): WebSource[] | null { + if (!Array.isArray(value)) return null + const sources: WebSource[] = [] + for (const source of value) { + if (typeof source !== 'object' || source === null || Array.isArray(source)) return null + const { url, title, snippet, publishedAt } = source as Record + if (typeof url !== 'string') return null + if (title !== undefined && typeof title !== 'string') return null + if (snippet !== undefined && typeof snippet !== 'string') return null + if (publishedAt !== undefined && typeof publishedAt !== 'string') return null + sources.push({ + url, + ...title === undefined ? {} : { title }, + ...snippet === undefined ? {} : { snippet }, + ...publishedAt === undefined ? {} : { publishedAt }, + }) + } + return sources +} + +/** + * Derive a settled root web-search or web-fetch card from persisted metadata. + * @param block - running or settled Tool block. + * @returns web-card props, or null for the generic path. + */ +export function webCardModel(block: ToolCallBlock): WebCardModelProps | null { + if (block.parentCallId !== undefined || !('kind' in block) || block.isError) return null + const tool = validWebCall(block) + if (tool === null || typeof block.meta !== 'object' || block.meta === null || Array.isArray(block.meta)) return null + const meta = block.meta as Record + if (typeof meta.truncated !== 'boolean') return null + if (tool === 'web_search') { + const sources = webSources(meta.sources) + if (sources === null || (meta.answer !== undefined && typeof meta.answer !== 'string')) return null + return { + kind: 'search', + answer: meta.answer, + sources, + truncated: meta.truncated, + } + } + if (typeof meta.url !== 'string') return null + if (typeof meta.statusCode !== 'number' || !Number.isInteger(meta.statusCode)) return null + return { + kind: 'fetch', + url: meta.url, + statusCode: meta.statusCode, + truncated: meta.truncated, + } +} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index 32d7bb0dae..0fe0883e0a 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -47,7 +47,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, toolName={toolName} icon={VARIANT_ICONS[model.variant]} title={t(model.titleKey)} - summary={terminal?.description ?? search?.title ?? model.summary} + summary={terminal?.description ?? model.summary} // Single-file tools never expose an args body — the path link is the only // args interaction. A card is not an args body: a read/write/edit row is // single-file AND carries a card, so the card expands under the path link. diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index a408110581..eaba61f015 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -35,8 +35,8 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { /** Renders expandable Bash output with an accessible lifecycle label. */ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { const model = toolRowModel(toolName, block) - // Session workspace root: the terminal view's cwd resolves against it (an - // omitted workdir IS the workspace), which the pure presenter cannot do. + // An omitted shell workdir is the session workspace; relative values resolve + // against it before reaching the terminal primitive. const cwd = useSessions(list => list.byId[sessionId]?.cwd) const terminal = terminalCardModel(block, cwd) // A failing exit status is the terminal card's own error signal (the call @@ -47,7 +47,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: const status = stateStatus(state, t) const [expanded, setExpanded] = useState(false) // Execution failures (for example cancellation before the process reports a - // terminal result) use the generic presenter. Keep their recorded args and + // terminal result) use the generic body. Keep their recorded args and // full error reachable instead of collapsing the row to the first line. const genericError = terminal === null && model.state === 'error' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index dfd33251de..4bb2617d57 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -27,7 +27,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { title={t(toolName === 'grep' ? SEARCH_TITLE_KEYS.grep : toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)} - summary={search?.title ?? model.summary} + summary={model.summary} body={null} // ToolRow ignores output when a structured card is present; otherwise it // preserves the generic fallback for errors and legacy results. diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx index 976ba3df53..29632827d7 100644 --- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx @@ -24,11 +24,11 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial - ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] }) + ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] }) const t = makeTranslate(zh, commonZh) diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index f30bb1a3bd..f6e0ef3098 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -46,7 +46,7 @@ const todoResult = (seq: number): ToolResultNode => ({ kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`, call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) }, callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, subCalls: [], + content: [], isError: false, subCalls: [], }) const bashResult = (seq: number, callId: string, over?: Partial): ToolResultNode => ({ @@ -54,8 +54,6 @@ const bashResult = (seq: number, callId: string, over?: Partial) call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, callTime: seq * 1_000 - 500, content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false, - callView: { card: 'terminal', title: 'ls -la', description: 'List files' }, - resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 }, subCalls: [], ...over, }) @@ -142,8 +140,10 @@ describe('terminal card assembly', () => { it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => { const runtime = await bench([ bashResult(3, 'c-keyed'), - // An unregistered tool with terminal views: GenericToolCard fallback. - bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }), + // pwsh has no package-local keyed row, so GenericToolCard owns its raw terminal card. + bashResult(4, 'c-fallback', { + call: { name: 'pwsh', argsRaw: '{"command":"ls -la","description":"List files"}' }, + }), ]) const view = runtime.renderRoot() @@ -157,7 +157,7 @@ describe('terminal card assembly', () => { }) // Fallback row: same unified expand interaction. - const fallback = view.container.querySelector('[data-tool="fx-bash"]') + const fallback = view.container.querySelector('[data-tool="pwsh"]') expect(fallback).not.toBeNull() expect(fallback!.querySelector('[data-terminal]')).toBeNull() fireEvent.click(fallback!.querySelector('[data-expandable]')!) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index 79b4b84c12..ad7302d2c1 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -46,12 +46,12 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({ kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name: 'run_code', argsRaw: RUN_CODE_ARGS }, callTime: seq * 1_000 - 500, - content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null, + content: [{ type: 'text', text: 'demo.txt' }], isError: false, subCalls: [], }) const runningCode = (callId: string): RunningToolCall => ({ - callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null, + callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, subCalls: [], }) @@ -60,9 +60,10 @@ const subCall = ( ): ToolCallBlock => ({ kind: 'tool-result', seq, time: seq * 1_000, callId: `${parent}:code:${n}`, + parentCallId: parent, call: { name, argsRaw: JSON.stringify(args) }, callTime: seq * 1_000, - content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null, + content: [{ type: 'text', text: resultText }], isError, subCalls: [], }) @@ -244,7 +245,8 @@ describe('run_code sub-calls through the real chat machinery', () => { const parent = 'call-live' const runningSub: ToolCallBlock = { callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}', - turn: 0, step: 0, time: 21_000, callView: null, subCalls: [], + parentCallId: parent, + turn: 0, step: 0, time: 21_000, subCalls: [], } const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)])) const view = mountApp(b.runtime) @@ -260,7 +262,7 @@ describe('run_code sub-calls through the real chat machinery', () => { kind: 'tool-result', seq: 10, time: 10_000, callId: parent, call: { name: 'mystery', argsRaw: '{"n":1}' }, callTime: 9_500, - content: [], isError: false, callView: null, resultView: null, subCalls: [], + content: [], isError: false, subCalls: [], } const b = await bench(snapshotWith([plain], [])) const view = mountApp(b.runtime) diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 7c20a7c91f..723b2d1bd1 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -57,7 +57,7 @@ describe('Tool presentation tails', () => { kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', call: { name: 'todo_write', argsRaw: '{"note":"x"}' }, callTime: 1_000, - content: [], isError: false, callView: null, resultView: null, subCalls: [], + content: [], isError: false, subCalls: [], } const props: GenericToolCardProps = { callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t, @@ -72,7 +72,7 @@ describe('Tool presentation tails', () => { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' }, callTime: 2_000, - content: [], isError: false, callView: null, resultView: null, subCalls: [], + content: [], isError: false, subCalls: [], } const view = render() const row = view.container.querySelector('[data-sample="bash"]')! @@ -84,13 +84,13 @@ describe('Tool presentation tails', () => { it('BashRow carries data-state for running and StateDots for error/stopped', () => { const running: RunningToolCall = { callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}', - turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], + turn: 1, step: 1, time: 1_000, subCalls: [], } const errorResult: ToolResultNode = { kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"boom"}' }, callTime: 500, - content: [], isError: true, callView: null, resultView: null, subCalls: [], + content: [], isError: true, subCalls: [], } const stoppedResult: ToolResultNode = { ...errorResult, diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 91e6e365a9..3c113b3d3e 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -11,7 +11,6 @@ import type { } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts' @@ -34,21 +33,11 @@ const chatT = makeTranslate(chatZh, commonZh) const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}' -/** The edit tool's own call view (a call-time diff derived from the arguments). */ -const callDiff = (over?: Partial>): ToolCallView => ({ - card: 'diff', title: 'Edit notes/demo.txt', - diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, -}) - -/** The edit tool's own result view (the applied hunk diff). */ -const resultDiff = (over?: Partial>): ToolResultView => ({ - card: 'diff', title: 'Edit notes/demo.txt', - diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, -}) +const DIFFS = [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'edit', argsRaw: ARGS, - turn: 1, step: 1, time: 1_000, callView: callDiff(), subCalls: [], ...over, + turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) const settled = (over?: Partial): ToolResultNode => ({ @@ -56,60 +45,83 @@ const settled = (over?: Partial): ToolResultNode => ({ call: { name: 'edit', argsRaw: ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false, - callView: callDiff(), resultView: resultDiff(), subCalls: [], ...over, + meta: { diffs: DIFFS }, subCalls: [], ...over, }) describe('diffCardModel', () => { - it('derives a running card from the call view alone', () => { + it('derives a running card from raw edit arguments', () => { expect(diffCardModel(running())).toEqual({ card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] }, }) }) - it('derives a settled card from the result view, which replaces the call-time diff', () => { - // The applied hunks (result) win over the args-derived call diff. + it('preserves the Host presenter\'s whole-file diff for an empty old_string', () => { + expect(diffCardModel(running({ + argsRaw: '{"file_path":"notes/demo.txt","old_string":"","new_string":"replacement"}', + }))).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'replacement' }] }, + }) + }) + + it('derives a settled card from result metadata, which replaces the intended diff', () => { expect(diffCardModel(settled({ - resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }), + meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }, }))).toEqual({ card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }, }) }) - it('renders a settled diff even when the window dropped the call head', () => { - // A truncated call carries only the result view, which holds the whole change. - expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1) - }) - - it('returns null for every non-diff call: no views, generic views, unknown cards', () => { - expect(diffCardModel(running({ callView: null }))).toBeNull() - expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull() - expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull() - // A generic result settles a diff call on the generic path (write/edit's - // own execution-error arm). - expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() - // A card tag this UI version does not know arrives over the wire; the - // documented generic-card default takes it, not a crash. - const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView - expect(diffCardModel(running({ callView: future }))).toBeNull() + it('uses the intended write diff when successful metadata reports no applied hunk', () => { + const writeArgs = JSON.stringify({ file_path: 'notes/new.txt', content: 'hello fixture\n' }) expect(diffCardModel(settled({ - callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, - }))).toBeNull() + call: { name: 'write', argsRaw: writeArgs }, + meta: { diffs: [] }, + }))).toEqual({ + card: { diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture\n' }] }, + }) }) - it('falls back to null for a malformed diff payload off the wire', () => { - // toolEventViewSchema validates only the `card` string, so a version - // mismatch can deliver a diff card with an unusable diffs field. Each shape - // routes to the generic path instead of throwing inside DiffBlock. - const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView) - expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull() - expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull() - expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull() - expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull() - expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull() - expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull() - expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull() - // The running side narrows identically. - expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull() + it('returns null for missing calls, errors, malformed args, unrelated tools, and child dispatches', () => { + expect(diffCardModel(settled({ call: null }))).toBeNull() + expect(diffCardModel(settled({ isError: true }))).toBeNull() + expect(diffCardModel(running({ argsRaw: '{' }))).toBeNull() + expect(diffCardModel(running({ name: 'read' }))).toBeNull() + expect(diffCardModel(running({ parentCallId: 'parent' }))).toBeNull() + expect(diffCardModel(settled({ parentCallId: 'parent' }))).toBeNull() + }) + + it('keeps edit generic for missing or malformed applied metadata', () => { + expect(diffCardModel(settled({ meta: undefined }))).toBeNull() + expect(diffCardModel(settled({ meta: null }))).toBeNull() + expect(diffCardModel(settled({ meta: { diffs: 'nope' } }))).toBeNull() + expect(diffCardModel(settled({ meta: { diffs: [null] } }))).toBeNull() + expect(diffCardModel(settled({ meta: { diffs: [{ path: 1, oldText: null, newText: 'x' }] } }))).toBeNull() + expect(diffCardModel(settled({ meta: { diffs: [{ path: 'a', oldText: 5, newText: 'x' }] } }))).toBeNull() + expect(diffCardModel(settled({ meta: { diffs: [{ path: 'a', oldText: null, newText: 9 }] } }))).toBeNull() + }) + + it.each([ + undefined, + null, + { diffs: 'nope' }, + { diffs: [null] }, + ])('uses the intended write diff when applied metadata is absent or malformed: %j', (meta) => { + const writeArgs = JSON.stringify({ file_path: 'notes/new.txt', content: 'hello fixture\n' }) + expect(diffCardModel(settled({ + call: { name: 'write', argsRaw: writeArgs }, + meta, + }))).toEqual({ + card: { diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture\n' }] }, + }) + }) + + it('validates mutation escalation fields but accepts unrelated open-root fields', () => { + const args = (fields: Record) => JSON.stringify({ + file_path: 'notes/demo.txt', old_string: 'hello', new_string: 'hello fixture', ...fields, + }) + expect(diffCardModel(running({ argsRaw: args({ sandbox_permissions: 7, justification: 'Need access' }) }))).toBeNull() + expect(diffCardModel(running({ argsRaw: args({ sandbox_permissions: 'workspace-write' }) }))).toBeNull() + expect(diffCardModel(running({ argsRaw: args({ extension: { version: 1 } }) }))).not.toBeNull() }) }) @@ -142,7 +154,7 @@ describe('chat row diff body', () => { callId: 'c1', toolName: 'some_tool', openFile: vi.fn(), t, block: settled({ call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' }, - callView: null, resultView: null, + meta: undefined, }), }} />) fireEvent.click(view.container.querySelector('[data-expandable]')!) @@ -197,8 +209,7 @@ describe('FileMutationRow diff card', () => { const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}' const view = render() // The footer counts live inside the collapsed diff card. toggleRow(view) @@ -209,12 +220,12 @@ describe('FileMutationRow diff card', () => { const runningView = render() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() cleanup() - const errorView = render() + const errorView = render() expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() }) - it('a mutation call with no diff view renders the summary row alone', () => { - const view = render() + it('a mutation result with no metadata renders the summary row alone', () => { + const view = render() // No diff material: expanding shows the args-JSON body, never a diff card. expect(view.container.querySelector('[data-diff]')).toBeNull() toggleRow(view) @@ -222,11 +233,9 @@ describe('FileMutationRow diff card', () => { }) it('surfaces the result text when an errored mutation has no diff card', () => { - // write/edit return undefined from presentResult on isError, so the failure - // has no diff — ToolRow shows the model-facing error text as the collapsed - // summary's first line (errorSummary) instead of a bare red dot. + // Failed mutations have no diff; ToolRow keeps the model-facing error text. const view = render() expect(view.container.querySelector('[data-diff]')).toBeNull() @@ -235,7 +244,7 @@ describe('FileMutationRow diff card', () => { it('falls back to the error name/code when an errored result has no text block', () => { const view = render() expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy() @@ -252,7 +261,7 @@ describe('FileMutationRow diff card', () => { it('shows the stopped state when the call was interrupted', () => { const view = render() expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() @@ -264,7 +273,7 @@ describe('FileMutationRow diff card', () => { it('renders a plain summary span when the call carries no file path', () => { // Empty args leave deriveFilePath undefined, so the summary is not a link. const view = render() expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull() expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull() @@ -378,7 +387,7 @@ describe('DetailsPanel diff Output section', () => { it('a non-diff result keeps the flattened pre', () => { const view = mount(snapshot({ nodes: [settled({ - callView: null, resultView: null, + meta: undefined, content: [{ type: 'text', text: 'permission denied' }], })], }), target) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index dbf8a213f4..40b7042f30 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -13,7 +13,6 @@ import type { } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' @@ -35,7 +34,6 @@ const chatT = makeTranslate(chatZh, commonZh) // use it so the row exercises a production-shaped call. `web_fetch` (below) has // its own schema whose key is not `file_path`, so it keeps a `url`-less `path`. const ARGS = '{"file_path":"src/a.ts","offset":41}' -const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}' /** The read block's rendered content cells, one string per row (highlighting * breaks a line across token spans, so match on the row's textContent). */ @@ -50,26 +48,35 @@ const sampleLines = [ { number: 43, text: 'export const c = 3' }, ] -/** The read tool's own result view for a settled file read. */ -const resultRead = (over?: Partial>): ToolResultView => ({ - card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over, +interface ReadMetaFixture { + path: string + offset: number + lines: { number: number; text: string }[] + totalLines: number + lang?: string +} + +const readMeta = (over?: Partial): ReadMetaFixture => ({ + path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over, }) +const readContent = (body = 'export const a = 1'): string => `src/a.ts\nfile\n\n${body}\n` + const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'read', argsRaw: ARGS, - turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, subCalls: [], ...over, + turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) const settled = (over?: Partial): ToolResultNode => ({ kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', call: { name: 'read', argsRaw: ARGS }, callTime: 1_000, - content: [{ type: 'text', text: '41: export const a = 1' }], isError: false, - callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), subCalls: [], ...over, + content: [{ type: 'text', text: readContent() }], isError: false, + meta: readMeta(), subCalls: [], ...over, }) describe('readCardModel', () => { - it('derives the card from a settled read result view', () => { + it('derives the card from settled read metadata and its raw envelope', () => { expect(readCardModel(settled())).toEqual({ label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts', }) @@ -82,38 +89,31 @@ describe('readCardModel', () => { expect(model?.lines[0]).not.toBe(sampleLines[0]) }) - it('takes the result view\'s replacement title over the relativized path', () => { - // The presentation contract defines a result title as REPLACING the pending - // one, so a tool that supplies a label wins over the path here. - expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label) - .toBe('Read (head) src/a.ts') - }) - it('relativizes a workspace-rooted path label, and leaves others as authored', () => { // A workspace-rooted absolute path shows its short form. - expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label) + expect(readCardModel(settled({ meta: readMeta({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label) .toBe('src/a.ts') // A path outside the workspace stays as authored. - expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label) + expect(readCardModel(settled({ meta: readMeta({ path: '/srv/other.ts' }) }), '/w/app')?.label) .toBe('/srv/other.ts') // With no session cwd there is nothing to relativize against. - expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label) + expect(readCardModel(settled({ meta: readMeta({ path: '/w/app/src/a.ts' }) }))?.label) .toBe('/w/app/src/a.ts') }) it('abbreviates a leftover POSIX home path label', () => { - expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label) + expect(readCardModel(settled({ meta: readMeta({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label) .toBe('~/notes.md') - expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label) + expect(readCardModel(settled({ meta: readMeta({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label) .toBe('src/a.ts') - expect(readCardModel(settled({ resultView: resultRead({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label) + expect(readCardModel(settled({ meta: readMeta({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label) .toBe('C:\\Users\\u\\a.ts') }) it('carries an omitted language through as undefined', () => { - const noLang = resultRead() + const noLang = readMeta() delete (noLang as { lang?: string }).lang - expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined() + expect(readCardModel(settled({ meta: noLang }))?.lang).toBeUndefined() }) it('returns null for a running read: the read intent is result-side only', () => { @@ -122,19 +122,36 @@ describe('readCardModel', () => { expect(readCardModel(running())).toBeNull() }) - it('returns null for every non-read settled call: no view, generic view, unknown card', () => { - expect(readCardModel(settled({ resultView: null }))).toBeNull() - expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() - // A card tag this UI version does not know arrives over the wire; the - // documented generic-card default takes it, not a crash. - const future = { card: 'chart' } as unknown as ToolResultView - expect(readCardModel(settled({ resultView: future }))).toBeNull() + it('returns null for missing calls, errors, malformed metadata/envelopes, unrelated tools, and children', () => { + expect(readCardModel(settled({ call: null }))).toBeNull() + expect(readCardModel(settled({ isError: true }))).toBeNull() + expect(readCardModel(settled({ meta: undefined }))).toBeNull() + expect(readCardModel(settled({ meta: { ...readMeta(), lines: [{ number: 0, text: 'bad' }] } }))).toBeNull() + expect(readCardModel(settled({ content: [{ type: 'text', text: 'plain result' }] }))).toBeNull() + expect(readCardModel(settled({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull() + expect(readCardModel(settled({ parentCallId: 'parent' }))).toBeNull() + }) + + it.each([ + ['missing file_path', '{}'], + ['non-string file_path', '{"file_path":7}'], + ['blank file_path', '{"file_path":" "}'], + ['non-number offset', '{"file_path":"src/a.ts","offset":"41"}'], + ['non-positive offset', '{"file_path":"src/a.ts","offset":0}'], + ['fractional limit', '{"file_path":"src/a.ts","limit":1.5}'], + ])('keeps malformed recognized read args generic: %s', (_label, argsRaw) => { + expect(readCardModel(settled({ call: { name: 'read', argsRaw } }))).toBeNull() + }) + + it('accepts unknown fields because first-party parameter roots are open', () => { + const argsRaw = JSON.stringify({ file_path: 'src/a.ts', offset: 41, extension: { version: 1 } }) + expect(readCardModel(settled({ call: { name: 'read', argsRaw } }))).not.toBeNull() }) }) describe('GenericToolCard read body', () => { const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ - callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t, + callId: 'c1', toolName: 'read', block, openFile: vi.fn(), t, }) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ @@ -144,9 +161,7 @@ describe('GenericToolCard read body', () => { it('expands to the read card, capped tighter than the panel', () => { expect(CHAT_READ_MAX_LINES).toBeLessThan(16) - // web_fetch lands on the read variant without its own keyed row, so the - // fallback card owns the read block once expanded. - const view = render() + const view = render() // Collapsed: no read card in the DOM yet. expect(view.container.querySelector('[data-read]')).toBeNull() toggleRow(view) @@ -159,15 +174,15 @@ describe('GenericToolCard read body', () => { it('a non-read tool renders the bare row with no read card', () => { const view = render() toggleRow(view) expect(view.container.querySelector('[data-read]')).toBeNull() }) - it('a running read renders the summary row alone (no result view yet)', () => { - const view = render() + it('a running read renders the summary row alone (no result metadata yet)', () => { + const view = render() expect(view.container.querySelector('[data-read]')).toBeNull() }) }) @@ -229,7 +244,7 @@ describe('ReadRow keyed toolview', () => { it('an error read result shows the error state and no read card', () => { const view = render() expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error') @@ -238,7 +253,7 @@ describe('ReadRow keyed toolview', () => { it('an interrupted read shows the stopped state', () => { const view = render() expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped') }) @@ -323,7 +338,7 @@ describe('DetailsPanel Output section (read)', () => { it('renders the read card at full height, keeping the JSON Input section', () => { const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` })) const view = mount(snapshot({ - nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })], + nodes: [settled({ meta: readMeta({ offset: 1, lines: long, totalLines: 20 }) })], }), target) expect(view.getByText(/"file_path"/)).toBeTruthy() expect(view.container.querySelector('[data-read]')).not.toBeNull() @@ -335,7 +350,7 @@ describe('DetailsPanel Output section (read)', () => { it('a non-read result keeps the flattened pre form', () => { const view = mount(snapshot({ nodes: [settled({ - callView: null, resultView: null, + meta: undefined, content: [{ type: 'text', text: 'plain result' }], })], }), target) @@ -345,14 +360,14 @@ describe('DetailsPanel Output section (read)', () => { it('abbreviates a leftover POSIX home path on the read card label', () => { const view = mount(snapshot({ - nodes: [settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) })], + nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })], }), target, '/tmp/ws', { version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false, }) expect(view.getByText('~/notes.md')).toBeTruthy() }) - it('a running read keeps the 运行中… placeholder (no result view)', () => { + it('a running read keeps the 运行中… placeholder (no result metadata)', () => { const view = mount(snapshot({ runningCalls: [running()] }), target) expect(view.getByText('运行中…')).toBeTruthy() expect(view.container.querySelector('[data-read]')).toBeNull() diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index d1b3d8a1e0..e42e8103b6 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -11,7 +11,6 @@ import type { } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts' @@ -45,9 +44,23 @@ const SID = 's1' as SessionId const GREP_ARGS = '{"pattern":"foo","path":"src"}' const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}' -/** A grep result view: matches grouped by file. */ -const resultMatches = (over?: Partial>): ToolResultView => ({ - card: 'search', shape: 'matches', +interface MatchesMeta { + shape: 'matches' + files: { path: string; matches: { lineNumber: number; line: string }[] }[] + truncated: boolean + total: number +} + +interface PathsMeta { + shape: 'paths' + paths: string[] + truncated: boolean + total: number +} + +/** Persisted grep metadata: matches grouped by file. */ +const matchesMeta = (over?: Partial): MatchesMeta => ({ + shape: 'matches', files: [ { path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] }, { path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] }, @@ -55,14 +68,14 @@ const resultMatches = (over?: Partial>): ToolResultView => ({ - card: 'search', shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, +/** Persisted glob metadata: a flat path list. */ +const pathsMeta = (over?: Partial): PathsMeta => ({ + shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, }) const runningGrep = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'grep', argsRaw: GREP_ARGS, - turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, subCalls: [], ...over, + turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) const settledGrep = (over?: Partial): ToolResultNode => ({ @@ -70,7 +83,7 @@ const settledGrep = (over?: Partial): ToolResultNode => ({ call: { name: 'grep', argsRaw: GREP_ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false, - callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), subCalls: [], ...over, + meta: matchesMeta(), subCalls: [], ...over, }) const settledGlob = (over?: Partial): ToolResultNode => ({ @@ -78,13 +91,12 @@ const settledGlob = (over?: Partial): ToolResultNode => ({ call: { name: 'glob', argsRaw: GLOB_ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false, - callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), subCalls: [], ...over, + meta: pathsMeta(), subCalls: [], ...over, }) describe('searchCardModel', () => { - it('derives a matches card from the grep result view', () => { + it('derives a matches card from grep result metadata', () => { expect(searchCardModel(settledGrep())).toEqual({ - title: undefined, recovery: undefined, card: { kind: 'matches', @@ -97,84 +109,78 @@ describe('searchCardModel', () => { }) }) - it('derives a paths card from the glob result view, carrying the truncation signal', () => { + it('derives a paths card from glob result metadata, carrying the truncation signal', () => { // Empty block content isolates the truncation signal from the recovery arm. - expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ - title: undefined, + expect(searchCardModel(settledGlob({ content: [], meta: pathsMeta({ truncated: true, total: 20 }) }))).toEqual({ recovery: undefined, card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 }, }) }) - it('carries the result view\'s replacement title when the presenter sets one', () => { - expect(searchCardModel(settledGrep({ resultView: resultMatches({ title: '3 matches' }) }))?.title).toBe('3 matches') - // Without one it is absent, so the row keeps its args-derived summary. - expect(searchCardModel(settledGrep())?.title).toBeUndefined() - }) - - it('returns null for every non-search call: running, no views, generic, terminal, unknown cards', () => { - // A search card is result-time only: a running call has no result view yet. + it('returns null for running, missing calls, errors, malformed args, unrelated tools, and children', () => { expect(searchCardModel(runningGrep())).toBeNull() - expect(searchCardModel(settledGrep({ callView: null, resultView: null }))).toBeNull() - // A generic result settles a search call as a generic card (grep/glob failure - // or a nested run_code dispatch), which keeps the generic path. - expect(searchCardModel(settledGrep({ resultView: { card: 'generic' } }))).toBeNull() - // A terminal result view is a different card entirely. - expect(searchCardModel(settledGrep({ resultView: { card: 'terminal', output: 'x' } }))).toBeNull() - // A card tag this UI version does not know arrives over the wire; the - // documented generic-card default takes it, not a crash. - const future = { card: 'chart' } as unknown as ToolResultView - expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull() + expect(searchCardModel(settledGrep({ call: null }))).toBeNull() + expect(searchCardModel(settledGrep({ isError: true }))).toBeNull() + expect(searchCardModel(settledGrep({ call: { name: 'grep', argsRaw: '{' } }))).toBeNull() + expect(searchCardModel(settledGrep({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull() + expect(searchCardModel(settledGrep({ parentCallId: 'parent' }))).toBeNull() }) - it('returns null for a card:search view whose shape this version does not compile', () => { - // `shape` rides the same untrusted wire frame as `card`; a subtype this client - // does not know must fall to the generic path, never render as a paths card - // that would crash SearchBlock on an absent `paths`. - const futureShape = { - card: 'search', shape: 'future', truncated: false, total: 0, - } as unknown as ToolResultView - expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull() + it('returns null for metadata whose shape does not match the tool', () => { + expect(searchCardModel(settledGrep({ meta: { shape: 'future', truncated: false, total: 0 } }))).toBeNull() + expect(searchCardModel(settledGrep({ meta: pathsMeta() }))).toBeNull() + expect(searchCardModel(settledGlob({ meta: matchesMeta() }))).toBeNull() + }) + + it('validates declared search argument fields and accepts open-root extensions', () => { + expect(searchCardModel(settledGrep({ + call: { name: 'grep', argsRaw: '{"pattern":"foo","include":7}' }, + }))).toBeNull() + expect(searchCardModel(settledGrep({ + call: { name: 'grep', argsRaw: '{"pattern":"foo","include":"!*.ts"}' }, + }))).toBeNull() + expect(searchCardModel(settledGlob({ + call: { name: 'glob', argsRaw: '{"pattern":"**/*.ts","path":7}' }, + }))).toBeNull() + expect(searchCardModel(settledGrep({ + call: { name: 'grep', argsRaw: '{"pattern":"foo","extension":1}' }, + }))).not.toBeNull() }) it('returns null for a known shape whose structured shape is missing or malformed', () => { - // The host wire schema checks the `card`/`shape` strings but not the grouped - // shape, so a version mismatch could deliver shape:'matches' with no `files` - // (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at - // `.reduce`/`.map`; the derivation drops to the generic path instead. - const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView - expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull() + const noFiles = { shape: 'matches', truncated: false, total: 0 } + expect(searchCardModel(settledGrep({ meta: noFiles }))).toBeNull() const badFile = { - card: 'search', shape: 'matches', truncated: false, total: 1, + shape: 'matches', truncated: false, total: 1, files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }], - } as unknown as ToolResultView - expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull() - const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView - expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull() + } + expect(searchCardModel(settledGrep({ meta: badFile }))).toBeNull() + const noPaths = { shape: 'paths', truncated: false, total: 0 } + expect(searchCardModel(settledGlob({ meta: noPaths }))).toBeNull() const badPaths = { - card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42], - } as unknown as ToolResultView - expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull() + shape: 'paths', truncated: false, total: 1, paths: [42], + } + expect(searchCardModel(settledGlob({ meta: badPaths }))).toBeNull() }) it('surfaces the recovery text only when the result was capped', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' - // The recovery locator lives in the raw tool/result content (the view carries - // no text), surfaced only when the card capped the result. + // The recovery locator lives in raw tool/result content and is surfaced only + // when metadata says the card was capped. const capped = searchCardModel(settledGrep({ content: [{ type: 'text', text: recovery }], - resultView: resultMatches({ truncated: true, total: 42 }), + meta: matchesMeta({ truncated: true, total: 42 }), })) expect(capped?.recovery).toBe(recovery) // Not capped: the card holds every match, so the raw content adds nothing and // is dropped. const whole = searchCardModel(settledGrep({ content: [{ type: 'text', text: recovery }], - resultView: resultMatches({ truncated: false }), + meta: matchesMeta({ truncated: false }), })) expect(whole?.recovery).toBeUndefined() // Capped but the block carries no text: nothing to surface. - const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) })) + const noText = searchCardModel(settledGrep({ content: [], meta: matchesMeta({ truncated: true, total: 42 }) })) expect(noText?.recovery).toBeUndefined() }) }) @@ -210,7 +216,7 @@ describe('chat row search body (GenericToolCard fallback)', () => { it('a non-search result keeps the args-JSON text body', () => { const view = render() toggleRow(view) expect(view.getByText(/"pattern"/)).toBeTruthy() @@ -221,7 +227,7 @@ describe('chat row search body (GenericToolCard fallback)', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' const view = render() toggleRow(view) expect(searchKindOf(view.container)).toBe('matches') @@ -266,20 +272,20 @@ describe('SearchRow keyed card', () => { it('agrees with the summary row about the run state', () => { const runningView = render() expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running') - // No result view yet, so no card even once material could expand. + // No result metadata yet, so no card even once material could expand. expect(searchKindOf(runningView.container)).toBeNull() cleanup() const errorView = render() expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error') }) it('surfaces the result text through the Output section when an errored search has no card', () => { - // grep/glob return no presentResult on error → no card; the row shows the - // first error line as the collapsed summary and the full text once expanded. + // Failed search metadata cannot select a success card; the row keeps the + // first error line collapsed and the full text once expanded. const view = render() expect(searchKindOf(view.container)).toBeNull() @@ -291,12 +297,9 @@ describe('SearchRow keyed card', () => { }) it('surfaces the result text for a settled non-error call with no card once expanded', () => { - // A successful nested run_code sub-dispatch (backend computes no - // presentationMeta, so resultView is null) or a legacy generic result settles - // with search === null and state ok. The keyed SearchRow owns the slot, so - // ToolRow's Output section carries the text; it is only visible expanded. + // Missing metadata keeps a successful result on ToolRow's raw Output path. const view = render() expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok') @@ -311,7 +314,7 @@ describe('SearchRow keyed card', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' const view = render() toggleRow(view) expect(searchKindOf(view.container)).toBe('matches') @@ -327,21 +330,14 @@ describe('SearchRow keyed card', () => { it('falls back to the error name/code when an errored result has no text block', () => { const view = render() // Error state: the derived name/code line is the collapsed summary. expect(view.getByText('ToolError: timeout')).toBeTruthy() }) - it('shows the result view\'s replacement title instead of the args summary', () => { - const view = render() - expect(view.getByText('3 matches in 2 files')).toBeTruthy() - }) - - it('keeps the args-derived summary when the result view has no title', () => { + it('keeps the args-derived summary beside the metadata-derived card', () => { const view = render() expect(view.getByText('foo')).toBeTruthy() }) @@ -441,7 +437,7 @@ describe('DetailsPanel Output section (search)', () => { it('renders the recovery footer below the card for a capped search', () => { const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)' const view = mount(snapshot({ - nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })], + nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], meta: pathsMeta({ truncated: true, total: 23 }) })], }), globTarget) expect(searchKindOf(view.container)).toBe('paths') expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy() @@ -449,7 +445,7 @@ describe('DetailsPanel Output section (search)', () => { it('a non-search result keeps the flattened pre form', () => { const view = mount(snapshot({ - nodes: [settledGrep({ callView: null, resultView: null })], + nodes: [settledGrep({ meta: undefined })], }), grepTarget) expect(searchKindOf(view.container)).toBeNull() const output = view.getByText('输出').closest('section') diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index c7cd3a2ffa..2543f7bfe7 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -11,7 +11,6 @@ import type { } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts' @@ -46,19 +45,13 @@ const SID = 's1' as SessionId const ARGS = '{"command":"ls -la","description":"List files"}' -/** The bash tool's own call view for a foreground command. */ -const callTerminal = (over?: Partial>): ToolCallView => ({ - card: 'terminal', title: 'ls -la', description: 'List files', ...over, -}) - -/** The bash tool's own result view for a settled foreground command. */ -const resultTerminal = (over?: Partial>): ToolResultView => ({ - card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over, +const shellArgs = (over: Record = {}): string => JSON.stringify({ + command: 'ls -la', description: 'List files', ...over, }) const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'bash', argsRaw: ARGS, - turn: 1, step: 1, time: 1_000, callView: callTerminal(), subCalls: [], ...over, + turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) const settled = (over?: Partial): ToolResultNode => ({ @@ -66,12 +59,12 @@ const settled = (over?: Partial): ToolResultNode => ({ call: { name: 'bash', argsRaw: ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false, - callView: callTerminal(), resultView: resultTerminal(), subCalls: [], ...over, + subCalls: [], ...over, }) describe('terminalCardModel', () => { - it('derives a running card from the call view alone', () => { - expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({ + it('derives a running standard-shell card from raw arguments', () => { + expect(terminalCardModel(running({ argsRaw: shellArgs({ workdir: '/projects/app' }) }))).toEqual({ description: 'List files', card: { command: 'ls -la', cwd: '/projects/app', output: undefined, @@ -80,61 +73,59 @@ describe('terminalCardModel', () => { }) }) - it('derives a settled card from both sides, carrying the exit status', () => { + it('derives a settled standard-shell card and removes its final exit marker', () => { expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '/projects/app' }), - resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }), + call: { name: 'bash', argsRaw: shellArgs({ workdir: '/projects/app' }) }, + content: [{ type: 'text', text: 'boom\n[exit code: 2]' }], }))).toEqual({ description: 'List files', card: { - command: 'ls -la', cwd: '/projects/app', output: 'boom\n', + command: 'ls -la', cwd: '/projects/app', output: 'boom', exitCode: 2, signal: undefined, running: false, }, }) expect(terminalCardModel(settled({ - resultView: { card: 'terminal', output: '', signal: 'SIGTERM' }, - }))?.card.signal).toBe('SIGTERM') + content: [{ type: 'text', text: 'gone\n[killed by signal: SIGTERM]' }], + }))?.card).toMatchObject({ output: 'gone', signal: 'SIGTERM' }) }) it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => { // isError stays false on a failing command (the exit status is result // data), so this predicate is the row's only failure signal. expect(terminalFailed(terminalCardModel(settled({ - resultView: resultTerminal({ exitCode: 2 }), + content: [{ type: 'text', text: 'boom\n[exit code: 2]' }], }))!)).toBe(true) expect(terminalFailed(terminalCardModel(settled({ - resultView: { card: 'terminal', output: '', signal: 'SIGTERM' }, + content: [{ type: 'text', text: 'gone\n[killed by signal: SIGTERM]' }], }))!)).toBe(true) expect(terminalFailed(terminalCardModel(settled())!)).toBe(false) expect(terminalFailed(terminalCardModel(running())!)).toBe(false) }) - it('takes the result view\'s replacement title over the pending one', () => { - // The presentation contract defines a result title as REPLACING the pending - // title, so a tool that rewrites it at settle time must win here. + it('keeps status text that has no terminal pill and requires a leading newline', () => { expect(terminalCardModel(settled({ - callView: callTerminal({ title: 'pnpm run check' }), - resultView: resultTerminal({ title: 'pnpm run check --filter web' }), - }))?.card.command).toBe('pnpm run check --filter web') - // Without one, the call's title is what the card keeps. - expect(terminalCardModel(settled())?.card.command).toBe('ls -la') + content: [{ type: 'text', text: 'timed out\n[timed out after 1000ms]\n[exit code: 2]' }], + }))?.card).toMatchObject({ output: 'timed out\n[timed out after 1000ms]', exitCode: 2 }) + expect(terminalCardModel(settled({ + content: [{ type: 'text', text: '[exit code: 5]' }], + }))?.card).toMatchObject({ output: '[exit code: 5]', exitCode: 0 }) }) - it('resolves the cwd against the session workspace the way the bridge must', () => { + it('resolves the raw workdir against the session workspace', () => { // Omitted workdir — the common bash call — IS the session workspace. expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app') // A relative workdir joins under it. expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: 'packages/ui' }), + call: { name: 'bash', argsRaw: shellArgs({ workdir: 'packages/ui' }) }, }), '/w/app')?.card.cwd).toBe('/w/app/packages/ui') // An absolute one is used as-is. expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '/srv/other' }), + call: { name: 'bash', argsRaw: shellArgs({ workdir: '/srv/other' }) }, }), '/w/app')?.card.cwd).toBe('/srv/other') // With no session cwd there is nothing to resolve against: a relative path // stays as authored and an omitted one stays absent (a bare `$` prompt). expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: 'packages/ui' }), + call: { name: 'bash', argsRaw: shellArgs({ workdir: 'packages/ui' }) }, }))?.card.cwd).toBe('packages/ui') expect(terminalCardModel(settled())?.card.cwd).toBeUndefined() // The running arm resolves identically. @@ -144,97 +135,119 @@ describe('terminalCardModel', () => { it('normalizes a relative workdir so the label names the directory actually used', () => { // The bash executor resolves the workdir before running, so `..` against // /w/app runs in /w — the card must say `w`, not `..`. - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '..' }), - }), '/w/app')?.card.cwd).toBe('/w') - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '.' }), - }), '/w/app')?.card.cwd).toBe('/w/app') - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '../sibling' }), - }), '/w/app')?.card.cwd).toBe('/w/sibling') - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: './nested/../other' }), - }), '/w/app')?.card.cwd).toBe('/w/app/other') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '/w/app')?.card.cwd).toBe('/w') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '.' }) } }), '/w/app')?.card.cwd).toBe('/w/app') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../sibling' }) } }), '/w/app')?.card.cwd).toBe('/w/sibling') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: './nested/../other' }) } }), '/w/app')?.card.cwd).toBe('/w/app/other') // A `..` that would climb past the root is dropped, as a filesystem does. - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '../../..' }), - }), '/w')?.card.cwd).toBe('/') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../../..' }) } }), '/w')?.card.cwd).toBe('/') // An absolute path carrying segments normalizes too. - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '/srv/./app/../other' }), - }), '/w/app')?.card.cwd).toBe('/srv/other') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '/srv/./app/../other' }) } }), '/w/app')?.card.cwd).toBe('/srv/other') // A Windows path keeps its separators. - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }), - }), '/w')?.card.cwd).toBe('C:\\ws') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: 'C:\\ws\\app\\..' }) } }), '/w')?.card.cwd).toBe('C:\\ws') // Without a session cwd a relative `..` has nothing to resolve against, so // it survives as authored rather than being silently dropped. - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '../elsewhere' }), - }))?.card.cwd).toBe('../elsewhere') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../elsewhere' }) } }))?.card.cwd).toBe('../elsewhere') }) it('keeps a UNC server and share as an unpoppable root', () => { // Windows cannot climb above a share, so `..` from the share root stays put. - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '..' }), - }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share') // Below the share it pops normally, keeping the UNC separators. - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '..' }), - }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share') // Several `..` cannot escape the root either. - expect(terminalCardModel(settled({ - callView: callTerminal({ cwd: '../../..' }), - }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share') + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../../..' }) } }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share') }) - it('draws a bare $ when the window dropped the call head, rather than guessing', () => { - // A truncated call carries no cwd anywhere: the result view has none, and - // the original call may have used an explicit workdir. Falling back to the - // session workspace here would name a directory the card cannot know. + it('supports terminal_send without giving background or failed sends a terminal card', () => { + const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: 'make' }) + const run = running({ name: 'terminal_send', argsRaw }) + expect(terminalCardModel(run, '/w/app')).toMatchObject({ + description: 'Terminal pty-3', card: { command: 'make', cwd: '/w/app', running: true }, + }) + const done = settled({ call: { name: 'terminal_send', argsRaw }, content: [{ type: 'text', text: 'ok' }] }) + expect(terminalCardModel(done)?.card).toMatchObject({ command: 'make', output: 'ok', running: false }) expect(terminalCardModel(settled({ - call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }), - }), '/w/app')?.card.cwd).toBeUndefined() - // A present call view that omits its cwd still means the workspace. - expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app') - }) - - it('carries the call view\'s description, which the contract renders above the card', () => { - expect(terminalCardModel(settled())?.description).toBe('List files') - expect(terminalCardModel(running())?.description).toBe('List files') - // A presenter that supplies none, and a window-truncated call side, both - // leave it absent so the row keeps its args-derived summary. - expect(terminalCardModel(settled({ - callView: { card: 'terminal', title: 'ls' }, - }))?.description).toBeUndefined() - expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined() - }) - - it('a window-truncated call side falls back to the result title, then to an empty command', () => { - // Truncation drops both the call head and its view (conversation.ts). - const truncated = { call: null, callView: null } - expect(terminalCardModel(settled({ - ...truncated, resultView: resultTerminal({ title: 'ls -la' }), - }))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false }) - expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined }) - }) - - it('returns null for every non-terminal call: no views, generic views, unknown cards', () => { - expect(terminalCardModel(running({ callView: null }))).toBeNull() - expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull() - expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull() - // A generic result settles a terminal call as a generic card (the bash - // tool's own execution-error and background paths). - expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() - // A card tag this UI version does not know arrives over the wire; the - // documented generic-card default takes it, not a crash. - const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView - expect(terminalCardModel(running({ callView: future }))).toBeNull() - expect(terminalCardModel(settled({ - callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, + call: { name: 'terminal_send', argsRaw: JSON.stringify({ sessionId: 'pty-3', text: 'make', run_in_background: true }) }, }))).toBeNull() + expect(terminalCardModel(settled({ ...done, isError: true }))).toBeNull() + }) + + it('preserves persistent-shell running cards and settled generic output', () => { + const persistent = JSON.stringify({ command: 'pwd' }) + expect(terminalCardModel(running({ argsRaw: persistent }))).toMatchObject({ + description: undefined, card: { command: 'pwd', running: true }, + }) + expect(terminalCardModel(running({ name: 'pwsh', argsRaw: persistent }))).toMatchObject({ + description: undefined, card: { command: 'pwd', running: true }, + }) + expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: persistent } }))).toBeNull() + expect(terminalCardModel(settled({ call: { name: 'pwsh', argsRaw: persistent } }))).toBeNull() + }) + + it('derives the standard pwsh card from the same raw status markers', () => { + expect(terminalCardModel(settled({ + call: { name: 'pwsh', argsRaw: ARGS }, + content: [{ type: 'text', text: 'failed\n[exit code: 3]' }], + }))).toMatchObject({ + description: 'List files', + card: { command: 'ls -la', output: 'failed', exitCode: 3, running: false }, + }) + }) + + it('returns null without a paired call and for Code Dispatch children', () => { + expect(terminalCardModel(settled({ call: null }))).toBeNull() + expect(terminalCardModel(settled({ parentCallId: 'parent' }))).toBeNull() + expect(terminalCardModel(running({ parentCallId: 'parent' }))).toBeNull() + }) + + it('returns null for background, errors, malformed args, unsupported tools, and non-text results', () => { + expect(terminalCardModel(running({ argsRaw: shellArgs({ run_in_background: true }) }))).toBeNull() + expect(terminalCardModel(settled({ isError: true }))).toBeNull() + expect(terminalCardModel(running({ argsRaw: '{' }))).toBeNull() + expect(terminalCardModel(running({ name: 'read' }))).toBeNull() + expect(terminalCardModel(settled({ content: [] }))).toBeNull() + expect(terminalCardModel(settled({ content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }))).toBeNull() + }) + + it.each([ + ['timeout type', { timeoutMs: '1000' }], + ['timeout value', { timeoutMs: 0 }], + ['workdir type', { workdir: 7 }], + ['background type', { run_in_background: 'yes' }], + ['permission type', { sandbox_permissions: 7, justification: 'Need access' }], + ['permission value', { sandbox_permissions: 'read-only', justification: 'Need access' }], + ['missing justification', { sandbox_permissions: 'workspace-write' }], + ['orphan justification', { justification: 'Need access' }], + ['blank justification', { sandbox_permissions: 'workspace-write', justification: ' ' }], + ])('keeps malformed standard-shell optional fields generic: %s', (_label, fields) => { + expect(terminalCardModel(running({ argsRaw: shellArgs(fields) }))).toBeNull() + }) + + it('accepts valid optional and unknown standard-shell fields on the open parameter root', () => { + expect(terminalCardModel(running({ argsRaw: shellArgs({ + timeoutMs: 1_000, + sandbox_permissions: 'workspace-write', + justification: 'Write generated output', + extension: { version: 1 }, + }) }))).not.toBeNull() + }) + + it('validates terminal_send optional fields while retaining open-root extensions', () => { + const send = (over: Record) => running({ + name: 'terminal_send', + argsRaw: JSON.stringify({ sessionId: 'pty-1', text: 'make', ...over }), + }) + expect(terminalCardModel(send({ submit: 'yes' }))).toBeNull() + expect(terminalCardModel(send({ run_in_background: 'yes' }))).toBeNull() + expect(terminalCardModel(send({ submit: false, run_in_background: false }))).not.toBeNull() + expect(terminalCardModel(send({ extension: { version: 1 } }))).not.toBeNull() + }) + + it('keeps persistent shells with open-root extension fields on the running-card path', () => { + const argsRaw = JSON.stringify({ command: 'pwd', extension: { version: 1 } }) + expect(terminalCardModel(running({ argsRaw }))).not.toBeNull() + expect(terminalCardModel(running({ name: 'pwsh', argsRaw }))).not.toBeNull() }) }) @@ -263,7 +276,7 @@ describe('chat row terminal body', () => { it('a long output renders in full — the scroll container replaces the middle collapse', () => { const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`) const view = render() toggleRow(view) expect(view.getByText('line-5')).toBeTruthy() @@ -273,7 +286,7 @@ describe('chat row terminal body', () => { it('renders a multi-line command as one prompt row per line', () => { const view = render() toggleRow(view) const rows = view.container.querySelectorAll('[class^="_promptLine_"]') @@ -282,22 +295,17 @@ describe('chat row terminal body', () => { expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1) }) - it('the fallback row shows the presenter description, not the args summary', () => { - // Any terminal-declaring tool without its own keyed row lands here, so the - // contract's above-card description has to win at this render site as well. + it('the fallback row shows the call description', () => { const view = render() expect(view.getByText('Terminal 3')).toBeTruthy() expect(view.queryByText('List files')).toBeNull() }) - it('keeps the presenter description visible once the terminal card is expanded', () => { - // The contract puts the description ABOVE the card. The collapsed summary is - // hidden while a row is open, so an expanded terminal row has to draw it - // itself or the description would only ever be visible collapsed. + it('keeps the call description visible once the terminal card is expanded', () => { const view = render() expect(view.getByText('Terminal 3')).toBeTruthy() toggleRow(view) @@ -317,24 +325,24 @@ describe('chat row terminal body', () => { it('a non-terminal call keeps the args-JSON text body', () => { const view = render() toggleRow(view) expect(view.getByText(/"command"/)).toBeTruthy() }) - it('a terminal call with no args still expands, through its terminal body alone', () => { - // Empty args make the text body null; the terminal material carries the row. + it('malformed empty args use the generic output body', () => { const view = render() toggleRow(view) - expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + expect(view.container.querySelector('[class*="_ioText_"]')?.textContent).toBe('a.ts b.ts\nc.ts d.ts\n') + expect(view.container.querySelector('[data-terminal]')).toBeNull() }) it('a failing exit status surfaces as the collapsed row\'s error state', () => { const view = render() expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error') }) @@ -386,34 +394,32 @@ describe('BashRow terminal card', () => { it('a failing exit status surfaces as the collapsed row\'s error state', () => { const view = render() expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error') }) - it('shows the terminal presenter\'s description instead of the args summary', () => { - // `terminal_send`-style presenters author a description the args do not - // repeat; the contract puts it above the card, which is this row's summary. + it('shows the call description as the terminal summary', () => { const view = render() expect(view.getByText('Terminal 3')).toBeTruthy() expect(view.queryByText('List files')).toBeNull() }) - it('keeps the args-derived summary when the presenter authored no description', () => { + it('keeps the command summary for a persistent shell with no description', () => { const view = render() - expect(view.getByText('List files')).toBeTruthy() + expect(view.getByText('ls -la')).toBeTruthy() }) it('a non-terminal bash call (background start) renders the summary row alone', () => { const view = render() - expect(view.getByText('List files')).toBeTruthy() + expect(view.getByText('Wait')).toBeTruthy() expect(view.queryByText(/a\.ts/)).toBeNull() expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull() }) @@ -422,8 +428,6 @@ describe('BashRow terminal card', () => { const view = render() const row = view.container.querySelector('[data-sample="bash"]')! expect(row.getAttribute('role')).toBe('button') @@ -498,7 +502,7 @@ describe('DetailsPanel Output section', () => { it('resets the card\'s expand state when the selected call changes', () => { const long = Array.from({ length: 20 }, (_, i) => `row-${i}`) const view = mount(snapshot({ - nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })], + nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })], }), target) fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' })) expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy() @@ -506,15 +510,15 @@ describe('DetailsPanel Output section', () => { cleanup() const second = mount(snapshot({ nodes: [settled({ - callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }), + callId: 'c2', content: [{ type: 'text', text: `${long.join('\n')}\n` }], })], }), { turnSeq: 10, callId: 'c2', toolName: 'bash' }) expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy() }) - it('renders the presenter description above the card', () => { + it('renders the raw call description above the card', () => { const view = mount(snapshot({ - nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })], + nodes: [settled({ call: { name: 'bash', argsRaw: shellArgs({ description: 'Terminal 3' }) } })], }), target) const description = view.getByText('Terminal 3') const card = view.container.querySelector('[data-terminal]') @@ -525,14 +529,14 @@ describe('DetailsPanel Output section', () => { it('resolves the prompt cwd against the session workspace', () => { const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app') - // No workdir in the call view: the prompt label is the workspace basename. + // No workdir in the call args: the prompt label is the workspace basename. expect(view.getByText('app')).toBeTruthy() }) it('renders the terminal card at full height, keeping the JSON Input section', () => { const long = Array.from({ length: 20 }, (_, i) => `row-${i}`) const view = mount(snapshot({ - nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })], + nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })], }), target) expect(view.getByText(/"command"/)).toBeTruthy() expect(view.getByText('ls -la')).toBeTruthy() @@ -549,14 +553,14 @@ describe('DetailsPanel Output section', () => { }) it('a running non-terminal call keeps the 运行中… placeholder', () => { - const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target) + const view = mount(snapshot({ runningCalls: [running({ argsRaw: shellArgs({ run_in_background: true }) })] }), target) expect(view.getByText('运行中…')).toBeTruthy() }) it('a non-terminal result keeps the flattened pre with its error styling', () => { const view = mount(snapshot({ nodes: [settled({ - callView: null, resultView: null, isError: true, + isError: true, content: [{ type: 'text', text: 'permission denied' }], })], }), target) @@ -564,23 +568,8 @@ describe('DetailsPanel Output section', () => { expect(pre?.textContent).toBe('permission denied') }) - // The panel resolves a sub-dispatch through the same material as a native - // call, so a sub-call that DID carry terminal views would render the card. - // The shipped wire cannot produce that yet: `session.ts` folds - // `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and - // the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This - // pins the resolution path with views injected directly, and the arm below - // pins what the shipped path shows. - it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => { - const child = settled({ callId: 'c1' }) - const view = mount(snapshot({ - runningCalls: [running({ callId: 'p1', subCalls: [child] })], - }), target) - expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() - }) - - it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => { - const child = settled({ callId: 'c1', callView: null, resultView: null }) + it('a Code Dispatch child keeps the flattened form despite valid terminal raw fields', () => { + const child = settled({ callId: 'c1', parentCallId: 'p1' }) const view = mount(snapshot({ runningCalls: [running({ callId: 'p1', subCalls: [child] })], }), target) @@ -591,20 +580,24 @@ describe('DetailsPanel Output section', () => { expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts') }) - it('a running run_code sub-dispatch resolves through the running material', () => { + it('a running Code Dispatch child keeps the running placeholder', () => { const view = mount(snapshot({ // The leading non-matching sub-call exercises the scan's skip. runningCalls: [running({ callId: 'p1', - subCalls: [running({ callId: 'other' }), running()], + subCalls: [ + running({ callId: 'other', parentCallId: 'p1' }), + running({ parentCallId: 'p1' }), + ], })], }), target) - expect(view.getByText('ls -la')).toBeTruthy() + expect(view.getByText('运行中…')).toBeTruthy() + expect(view.container.querySelector('[data-terminal]')).toBeNull() }) it('a window-truncated call head titles the panel by callId and drops the Input section', () => { const view = mount(snapshot({ - nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })], + nodes: [settled({ call: null })], }), target) expect(view.getByText('c1')).toBeTruthy() expect(view.queryByText('输入')).toBeNull() @@ -674,7 +667,6 @@ describe('DetailsPanel Output section', () => { it('a non-text result block renders as JSON, and an empty result falls back to its error', () => { const nonText = mount(snapshot({ nodes: [settled({ - callView: null, resultView: null, content: [{ type: 'reasoning', text: 'why' }], })], }), target) @@ -685,7 +677,7 @@ describe('DetailsPanel Output section', () => { cleanup() const empty = mount(snapshot({ nodes: [settled({ - callView: null, resultView: null, content: [], isError: true, + content: [], isError: true, error: { name: 'ToolError', code: 'interrupted' }, })], }), target) diff --git a/packages/client/ui-tool/tests/todo-row.client.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx index 66739e7149..0012e65263 100644 --- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx @@ -62,7 +62,7 @@ describe('planSummary', () => { const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({ kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1', call: { name: 'todo_write', argsRaw }, - content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over, + content: [], isError: false, subCalls: [], ...over, }) function rowProps(block: unknown): TodoRowProps { @@ -94,7 +94,7 @@ describe('TodoRow', () => { it('omits the active clause when no item is in progress and reads running-call args', () => { const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] }) - render() + render() expect(screen.getByText('1/1 已完成')).toBeTruthy() }) @@ -107,7 +107,7 @@ describe('TodoRow', () => { it('keeps non-ok execution states visible through the shared row states', () => { const args = JSON.stringify({ todos: LIST }) - const running = render() + const running = render() expect(running.container.querySelector('[data-state="running"]')).not.toBeNull() expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull() running.unmount() diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 77fca2e7da..67cf3a7962 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -7,7 +7,7 @@ import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/cl import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { ToolTreeProps } from '../src/client/contract/slots.ts' +import type { ToolCallOwnerProps, ToolTreeProps } from '../src/client/contract/slots.ts' import { ToolCallTree } from '../src/client/tool/ToolCallTree.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' @@ -17,18 +17,21 @@ const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh) const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({ kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000, - content: [], isError: false, callView: null, resultView: null, subCalls: [], + content: [], isError: false, subCalls: [], }) function props( block: ToolResultNode, selectedCallId?: string, description?: HostDescription, + owners?: ToolCallOwnerProps[], ): ToolTreeProps { const snapshot = {} as SessionSnapshot const useSession = ((selector: (value: SessionSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] - const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) => - options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot'] + const renderSlot = ((_key: string, owner: ToolCallOwnerProps, options?: { fallback?: React.ReactNode }) => { + owners?.push(owner) + return options?.fallback ?? null + }) as unknown as ToolTreeProps['renderSlot'] return { useSession, renderSlot, @@ -64,16 +67,21 @@ describe('ToolCallTree', () => { }) it('recursively renders a selected leaf without selecting its ancestors', () => { - const leaf = root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' }) + const owners: ToolCallOwnerProps[] = [] + const leaf = { + ...root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' }), + parentCallId: 'parent:code:1', + } const child = { ...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }), + parentCallId: 'parent', subCalls: [leaf], } const block = { ...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }), subCalls: [child], } - const view = render() + const view = render() const nests = view.container.querySelectorAll('[data-subcalls]') expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]')) expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]')) @@ -81,6 +89,11 @@ describe('ToolCallTree', () => { expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false) expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true') expect(nests).toHaveLength(2) + expect(owners.map(owner => [owner.callId, owner.block.parentCallId ?? null])).toEqual([ + ['parent', null], + ['parent:code:1', 'parent'], + ['parent:code:1:code:1', 'parent:code:1'], + ]) }) it('abbreviates a POSIX home path in the generic tool summary', () => { diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 5d8b36e722..b915b39de9 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,6 +1,6 @@ /** Test adapter for the production conversation.details.tool registration. */ import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionEventEntry, SessionToolCallView } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' import type { ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps, @@ -28,12 +28,6 @@ function jsonFixture(value: unknown): JsonValue { return value as JsonValue } -function sessionCallView(view: NonNullable): SessionToolCallView { - if (view.card !== 'generic') return view - const { rawInput, ...wireView } = view - return rawInput === undefined ? wireView : { ...wireView, rawInput: jsonFixture(rawInput) } -} - /** Build the canonical Chat slice consumed by Tool rows and details tests. */ export function toolChatSnapshot( settled: readonly ConversationNode[] = [], @@ -110,7 +104,6 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se arguments: node.call.argsRaw, }, }, - ...(node.callView === null ? {} : { view: { for: 'call', view: sessionCallView(node.callView) } }), } entries.push(callEntry) const resultEntry: SessionEventEntry = { @@ -137,7 +130,6 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se }), surfaceOp: 'append', }, - ...(node.resultView === null ? {} : { view: { for: 'result', view: node.resultView } }), } entries.push(resultEntry) } diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index b891b70bc4..d295259908 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -18,14 +18,14 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}', - turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], ...over, + turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) const result = (over?: Partial): ToolResultNode => ({ kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, callTime: 1_000, - content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over, + content: [], isError: false, subCalls: [], ...over, }) describe('tool-call-model', () => { diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 2d9c2eb2d0..54a165cf40 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -38,7 +38,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: args }, callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, subCalls: [], + content: [], isError: false, subCalls: [], }) /** Test-owned AppFrame role: declares and renders the resident conversation area. */ diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index 184c0d6a11..2732f11aab 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -8,7 +8,6 @@ import type { } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, } from '@deepseek-ai/dsh-client-test-runtime' @@ -32,12 +31,24 @@ const SID = 's1' as SessionId const t = makeTranslate(zh, commonZh) const chatT = makeTranslate(chatZh, commonZh) -const SEARCH_ARGS = '{"query":"deepseek harness"}' +const SEARCH_ARGS = '{"queries":["deepseek harness"]}' const FETCH_ARGS = '{"url":"https://example.com/page"}' -/** A web_search result view; overrides tune the sources / answer / truncation. */ -const resultSearch = (over?: Partial>): ToolResultView => ({ - card: 'web', kind: 'search', truncated: false, +interface SearchMeta { + sources: { url: string; title?: string; snippet?: string; publishedAt?: string }[] + truncated: boolean + answer?: string +} + +interface FetchMeta { + url: string + statusCode: number + truncated: boolean +} + +/** Persisted web_search result metadata. */ +const searchMeta = (over?: Partial): SearchMeta => ({ + truncated: false, answer: 'A short answer.', sources: [ { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, @@ -46,14 +57,14 @@ const resultSearch = (over?: Partial>): ToolResultView => ({ - card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over, +/** Persisted web_fetch result metadata. */ +const fetchMeta = (over?: Partial): FetchMeta => ({ + url: 'https://example.com/page', statusCode: 200, truncated: false, ...over, }) const runningSearch = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS, - turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, subCalls: [], ...over, + turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) const settledSearch = (over?: Partial): ToolResultNode => ({ @@ -61,7 +72,7 @@ const settledSearch = (over?: Partial): ToolResultNode => ({ call: { name: 'web_search', argsRaw: SEARCH_ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'search text' }], isError: false, - callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), subCalls: [], ...over, + meta: searchMeta(), subCalls: [], ...over, }) const settledFetch = (over?: Partial): ToolResultNode => ({ @@ -69,53 +80,59 @@ const settledFetch = (over?: Partial): ToolResultNode => ({ call: { name: 'web_fetch', argsRaw: FETCH_ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'fetch body' }], isError: false, - callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), subCalls: [], ...over, + meta: fetchMeta(), subCalls: [], ...over, }) describe('webCardModel', () => { - it('derives a search card from the result view, projecting every source field', () => { + it('derives a search card from result metadata, projecting every source field', () => { expect(webCardModel(settledSearch())).toEqual({ kind: 'search', answer: 'A short answer.', truncated: false, sources: [ { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, - { url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined }, + { url: 'https://plain.example.org/b' }, ], }) }) it('carries the search truncation flag and an absent answer', () => { - const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } })) + const model = webCardModel(settledSearch({ meta: { truncated: true, sources: [] } })) expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] }) }) - it('derives a fetch card from the result view', () => { + it('derives a fetch card from result metadata', () => { expect(webCardModel(settledFetch())).toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, }) - expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) }))) + expect(webCardModel(settledFetch({ meta: fetchMeta({ statusCode: 404, truncated: true }) }))) .toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true }) }) it('returns null for a running call, since the web card is result-only', () => { expect(webCardModel(runningSearch())).toBeNull() - // Even a running call that somehow carried a web call view stays generic: - // the derivation reads resultView only. - expect(webCardModel(runningSearch({ callView: null }))).toBeNull() }) - it('returns null for a settled call whose result view is not a web card', () => { - expect(webCardModel(settledSearch({ resultView: null }))).toBeNull() - expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull() - // A card tag this UI version does not know arrives over the wire; the - // documented generic-card default takes it, not a crash. - const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView - expect(webCardModel(settledSearch({ resultView: future }))).toBeNull() - // A web card whose kind this UI version does not know (a newer host's - // value) also takes the generic path, not a malformed fetch. - const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView - expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull() + it('returns null for missing calls, errors, malformed args/meta, unrelated tools, and children', () => { + expect(webCardModel(settledSearch({ call: null }))).toBeNull() + expect(webCardModel(settledSearch({ isError: true }))).toBeNull() + expect(webCardModel(settledSearch({ call: { name: 'web_search', argsRaw: '{' } }))).toBeNull() + expect(webCardModel(settledSearch({ meta: undefined }))).toBeNull() + expect(webCardModel(settledSearch({ meta: { sources: [], truncated: 'yes' } }))).toBeNull() + expect(webCardModel(settledSearch({ call: { name: 'echo', argsRaw: '{}' } }))).toBeNull() + expect(webCardModel(settledSearch({ parentCallId: 'parent' }))).toBeNull() + }) + + it('accepts open-root extensions while validating declared web arguments', () => { + expect(webCardModel(settledSearch({ + call: { name: 'web_search', argsRaw: '{"queries":["deepseek"],"extension":1}' }, + }))).not.toBeNull() + expect(webCardModel(settledSearch({ + call: { name: 'web_search', argsRaw: '{"queries":[7]}' }, + }))).toBeNull() + expect(webCardModel(settledFetch({ + call: { name: 'web_fetch', argsRaw: '{"url":" "}' }, + }))).toBeNull() }) }) @@ -172,7 +189,7 @@ describe('chat row web body', () => { it('a failed web call keeps the summary row without the card', () => { const view = render() expect(view.getByText('网页搜索')).toBeTruthy() expect(view.container.querySelector('[data-web]')).toBeNull() @@ -180,19 +197,19 @@ describe('chat row web body', () => { expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() }) - it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => { + it('the GenericToolCard fallback does not promote an unknown tool from metadata alone', () => { const view = render() - expect(view.container.querySelector('[data-web]')).toBeNull() toggleRow(view) - expect(view.getByText('Titled')).toBeTruthy() - expect(view.container.querySelector('[data-web="search"]')).not.toBeNull() + expect(view.container.querySelector('[data-web]')).toBeNull() + expect(view.getByText('search text')).toBeTruthy() }) it('the GenericToolCard fallback keeps the plain row for a non-web call', () => { const view = render() expect(view.container.querySelector('[data-web]')).toBeNull() }) @@ -254,7 +271,7 @@ describe('DetailsPanel web Output section', () => { expect(view.getByText('Titled')).toBeTruthy() expect(view.getByText('excerpt')).toBeTruthy() // The Input JSON section survives beside it. - expect(view.getByText(/"query"/)).toBeTruthy() + expect(view.getByText(/"queries"/)).toBeTruthy() }) it('renders the fetch card and keeps the fetched body below it', () => { @@ -270,7 +287,7 @@ describe('DetailsPanel web Output section', () => { it('a non-web result keeps the flattened pre form', () => { const view = mount(snapshot({ - nodes: [settledSearch({ callView: null, resultView: null })], + nodes: [settledSearch({ meta: undefined })], }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) expect(view.container.querySelector('[data-web]')).toBeNull() const output = view.getByText('输出').closest('section') diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index 0a59df4a2e..fb4ca15abc 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -38,7 +38,6 @@ function rootCall(match: ConversationMatch): RunningToolCall { turn: match.event.data.turn, step: match.event.data.step, time: match.event.time, - callView: match.view?.for === 'call' ? match.view.view : null, subCalls: [], } } @@ -60,8 +59,6 @@ function rootResult( isError: result.isError === true, ...(match.event.data.error === undefined ? {} : { error: match.event.data.error }), meta: match.event.data.meta, - callView: previous?.callView ?? null, - resultView: match.view?.for === 'result' ? match.view.view : null, subCalls: [], } } @@ -79,12 +76,12 @@ function locationStep(match: ConversationMatch): number { function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { return { callId: data.subCallId, + parentCallId: data.parentCallId, name: data.name, argsRaw: JSON.stringify(data.arguments), turn: locationTurn(match), step: locationStep(match), time: match.event.time, - callView: null, subCalls: [], } } @@ -99,12 +96,11 @@ function childResult( seq: match.event.seq, time: match.event.time, callId: data.subCallId, + parentCallId: data.parentCallId, call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, callTime: previous === undefined || 'kind' in previous ? null : previous.time, content: data.content ?? [], isError: data.isError === true, - callView: null, - resultView: null, subCalls: [], } } @@ -190,13 +186,12 @@ function projectCall( seq: interruptedAt.seq - 0.8, time: interruptedAt.time, callId: block.callId, + ...block.parentCallId === undefined ? {} : { parentCallId: block.parentCallId }, call: { name: block.name, argsRaw: block.argsRaw }, callTime: block.time, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, - callView: block.callView, - resultView: null, subCalls, } } diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index c8ae7ec5a9..a454431d01 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -184,7 +184,7 @@ describe('Trajectory conversation Definitions', () => { }]) }) - it('keeps parallel interrupted roots and nests Code Dispatch results', () => { + it('keeps parallel roots, raw Tool facts, and nested Code Dispatch results', () => { const current = snapshot(assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'step/start', { turn: 1, step: 1 }), @@ -209,16 +209,44 @@ describe('Trajectory conversation Definitions', () => { arguments: { path: 'README.md' }, content: [{ type: 'text', text: 'contents' }], }), - at(7, 'step/end', { turn: 1, step: 1 }), + at(7, 'tool/result', { + turn: 1, + step: 1, + message: { + id: 'result-root-a', + role: 'user', + source: { kind: 'tool', callId: 'root-a' }, + content: [{ + type: 'tool-result', + toolCallId: 'root-a', + content: [{ type: 'text', text: 'root failed' }], + isError: true, + }], + }, + error: { name: 'ToolError', code: 'failed' }, + meta: { presentation: 'raw' }, + }, { surfaceOp: 'append' }), + at(8, 'step/end', { turn: 1, step: 1 }), ])) const tools = current.eventNodes.filter(node => node.kind === 'tool-result') expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b']) - expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{ + expect(tools.find(node => node.callId === 'root-a')).toMatchObject({ kind: 'tool-result', - callId: 'child', - call: { name: 'read' }, - }]) + callId: 'root-a', + call: { name: 'code', argsRaw: '{}' }, + content: [{ type: 'text', text: 'root failed' }], + isError: true, + error: { name: 'ToolError', code: 'failed' }, + meta: { presentation: 'raw' }, + subCalls: [{ + kind: 'tool-result', callId: 'child', parentCallId: 'root-a', call: { name: 'read' }, + }], + }) + expect(tools.find(node => node.callId === 'root-b')).toMatchObject({ + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + }) }) it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => { diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index 1cf5062a24..6df422e064 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -87,7 +87,7 @@ describe('deriveTrajectoryLayout', () => { { kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200, - content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, + content: [{ type: 'text', text: 'a.txt' }], isError: false, }, ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) @@ -113,7 +113,7 @@ describe('deriveTrajectoryLayout', () => { partial: null, runningCalls: [{ callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', - turn: 1, step: 2, time: 9_000, callView: null, subCalls: [], + turn: 1, step: 2, time: 9_000, subCalls: [], }], }) expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2']) @@ -177,7 +177,7 @@ describe('deriveTrajectoryLayout', () => { partial: { ...partial, blocks: [] }, runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}', - turn: 1, step: 1, time: 9_000, callView: null, subCalls: [], + turn: 1, step: 1, time: 9_000, subCalls: [], }], }) @@ -218,12 +218,12 @@ describe('deriveTrajectoryLayout', () => { { kind: 'tool-result', seq: 2, time: 2_500, callId: 'a', call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, }, { kind: 'tool-result', seq: 3, time: 4_000, callId: 'b', call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, }, ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) @@ -447,7 +447,7 @@ describe('deriveTrajectoryLayout', () => { { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, }, { kind: 'context', seq: 4, time: 9_000, @@ -502,7 +502,7 @@ describe('run_code sub-dispatch cells', () => { { kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1', call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200, - content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null, + content: [{ type: 'text', text: 'done' }], isError: false, subCalls: [], }, ] as unknown as LegacyConversationSlice['nodes'] @@ -511,7 +511,7 @@ describe('run_code sub-dispatch cells', () => { kind: 'tool-result' as const, seq: 100 + n, time: end, callId: `p1:code:${n}`, call: { name, argsRaw: '{"x":1}' }, callTime: start, - content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null, + content: [{ type: 'text' as const, text: 'ok' }], isError: false, subCalls: [], }) @@ -538,7 +538,7 @@ describe('run_code sub-dispatch cells', () => { it('a running (unsettled) sub-call renders a subtool cell with blank time', () => { const running = { callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}', - turn: 0, step: 0, time: 6_400, callView: null, subCalls: [], + turn: 0, step: 0, time: 6_400, subCalls: [], } const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] }) const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool') diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts index e65d8a22d6..59eec5f679 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts @@ -160,7 +160,6 @@ describe('TrajectorySnapshotBuilder', () => { turn: 1, step: 2, time: 7, - callView: null, subCalls: [], }, }), diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index a21e303788..cce6673be9 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -84,7 +84,7 @@ const NODES: LegacyConversationSlice['nodes'] = [ }, { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200, - content: [], isError: false, callView: null, resultView: null, subCalls: [], + content: [], isError: false, subCalls: [], }, { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [], diff --git a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts index 2d335202ff..02062489e7 100644 --- a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts +++ b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts @@ -10,7 +10,7 @@ const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return function running(over: Partial = {}): RunningToolCall { return { callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000, - callView: null, subCalls: [], ...over, + subCalls: [], ...over, } } @@ -19,7 +19,7 @@ function settled(over: Partial = {}): ToolResultNode { kind: 'tool-result', seq: 2, time: 2_000, callId: 'call-1', call: { name: 'cordis_define', argsRaw: ARGS }, callTime: 1_000, content: [{ type: 'text', text: 'defined dyn-1' }], isError: false, - meta: { pluginId: 'dyn-1', packageId: 'pkg-1' }, callView: null, resultView: null, subCalls: [], ...over, + meta: { pluginId: 'dyn-1', packageId: 'pkg-1' }, subCalls: [], ...over, } } diff --git a/packages/extensions/ui-cordis/tests/versioning.client.spec.ts b/packages/extensions/ui-cordis/tests/versioning.client.spec.ts index 54b39e8b91..568aecf232 100644 --- a/packages/extensions/ui-cordis/tests/versioning.client.spec.ts +++ b/packages/extensions/ui-cordis/tests/versioning.client.spec.ts @@ -40,7 +40,6 @@ describe('versioned Cordis card models', () => { turn: 1, step: 1, time: 1, - callView: null, subCalls: [], }) @@ -64,8 +63,6 @@ describe('versioned Cordis card models', () => { content: [{ type: 'text', text: 'running' }], isError: false, meta: { pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN }, - callView: null, - resultView: null, subCalls: [], }) @@ -87,7 +84,6 @@ describe('versioned Cordis card models', () => { turn: 1, step: 1, time: 1, - callView: null, subCalls: [], }) From a99516c3304561a2cd51a199d92e78746aa57bcc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:13:12 +0800 Subject: [PATCH 011/138] refactor(client): derive deliverables from mutation calls --- .../ui-deliverables/src/client/index.ts | 4 +- .../src/client/turn-deliverables.ts | 111 +++++++--- .../tests/produced-files.client.spec.tsx | 190 ++++++++++++++---- 3 files changed, 232 insertions(+), 73 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 879ed682e4..0e8767016e 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -2,8 +2,8 @@ * Deliverables plugin, browser half: registers the produced-files row into * the chat view's turn-tail chain, and provides the `chatFileMentions` * service that links inline-code mentions of produced files in the closing - * prose. All policy lives here — the derivation from the mutation tools' - * `locations`, the mention matching, the chip cap, and the copy — so + * prose. All policy lives here — the supported mutation calls, mention + * matching, chip cap, and copy — so * composing this plugin out of cordis.yml removes both surfaces entirely; * the owning view renders an empty chain and inert prose at zero cost. */ diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 604ab099f1..b7f74f305b 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -1,10 +1,10 @@ /** * Turn-scoped produced-file Definition and readers. Client-only and - * model-free: the vocabulary is the mutation tools' own follow-along - * `locations`, never the closing prose. + * model-free: the vocabulary comes from successful first-party mutation + * calls, never presentation data or the closing prose. */ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { ToolResultNode, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' @@ -27,38 +27,90 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface DeliverablesState extends DeliverablesTurnData { readonly turn: number - readonly calls: ReadonlyMap + readonly calls: ReadonlyMap } /** - * Paths a call view reports having created or changed, by render intent rather - * than tool name: a diff card, or a generic card whose kind is `edit` (the - * shape `str_replace_editor`'s insert presents). Every other card produces - * nothing to open — a read looked, a delete removed, a terminal ran. Only - * root call views enter this Turn accumulator; nested Code Mode dispatches - * preserve the pre-assembly behavior and do not contribute independently. + * Extract the path from a supported first-party mutation call. Session + * `tool/call` events are root calls; Code Dispatch children do not enter this + * Definition independently. + * @param name - wire tool name. + * @param argsRaw - model-produced JSON arguments. + * @returns the mutation path, or null when the call is not a supported mutation. */ -function producedPaths(view: ToolResultNode['callView']): readonly string[] { - if (view === null) return [] - if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) - if (view.card === 'generic' && view.kind === 'edit') { - return (view.locations ?? []).map(location => location.path) +function mutationPath(name: string, argsRaw: string): string | null { + let args: unknown + try { + args = JSON.parse(argsRaw) as unknown + } catch { + return null } - return [] + if (!isRecord(args)) return null + switch (name) { + case 'write': + return typeof args.content === 'string' ? pathValue(args.file_path) : null + case 'edit': + return validEditArgs(args) ? pathValue(args.file_path) : null + case 'str_replace_editor': + return editorMutationPath(args) + default: + return null + } +} + +/** Validate the fields that an `edit` execution requires. */ +function validEditArgs(args: Readonly>): boolean { + return typeof args.old_string === 'string' + && args.old_string.length > 0 + && typeof args.new_string === 'string' + && args.old_string !== args.new_string + && (args.replace_all === undefined || typeof args.replace_all === 'boolean') +} + +/** Extract a path only from a complete mutating editor command. */ +function editorMutationPath(args: Readonly>): string | null { + const path = pathValue(args.path) + if (path === null) return null + switch (args.command) { + case 'create': + return typeof args.file_text === 'string' ? path : null + case 'str_replace': + return typeof args.old_str === 'string' + && args.old_str.length > 0 + && (args.new_str === undefined || typeof args.new_str === 'string') + ? path + : null + case 'insert': + return typeof args.insert_line === 'number' + && Number.isInteger(args.insert_line) + && args.insert_line >= 0 + && typeof args.new_str === 'string' + ? path + : null + default: + return null + } +} + +/** A non-blank path preserves the exact spelling supplied to the tool. */ +function pathValue(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +/** Narrow parsed JSON to an argument object. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) } /** * Files produced by one Turn data value. * - * The source is the mutation tools' own follow-along `locations`, not the - * closing prose: a produced file must be listed whether or not the model - * remembered to name it. A mutation is recognized by render intent, not by - * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape - * `str_replace_editor`'s insert presents) — so a new mutation tool joins by - * declaring what it does. Reads contribute nothing (looking at a file does not - * produce it), and neither do deletes (there is nothing left to open) or - * failed calls. Paths keep first-seen order and appear once, so a file written - * and then edited in the same turn is one entry. + * The source is the arguments of successful `write`, `edit`, and mutating + * `str_replace_editor` calls, not the closing prose: a produced file must be + * listed whether or not the model remembered to name it. Reads, unsupported + * tools, malformed calls, and failed results contribute nothing. Paths keep + * first-seen order and appear once, so a file written and then edited in the + * same turn is one entry. * * The Conversation Location index owns turn membership before this function * runs, so paths cannot spill across turns and this derivation does not infer @@ -112,7 +164,7 @@ export const deliverablesDefinition: ConversationNodeDefinition ({ seq: match.event.seq, path })) - return additions.length === 0 + const path = context.state.calls.get(callId) + return path === null || path === undefined ? context.state - : { ...context.state, produced: [...context.state.produced, ...additions] } + : { ...context.state, produced: [...context.state.produced, { seq: match.event.seq, path }] } }, buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined ? null diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 7aef3b3580..4cbfe8c294 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -109,14 +109,12 @@ function at( seq: number, type: string, data: unknown, - view?: ConversationEventInput['view'], ): ConversationEventInput { return { event: { seq, time: seq * 1_000, type, data, ...(type === 'tool/result' ? { surfaceOp: 'append' } : {}), } as ConversationEventInput['event'], - ...(view === undefined ? {} : { view }), } } @@ -124,19 +122,27 @@ function matched(input: ConversationEventInput, role: ConversationMatch['role']) return { ...input, role, location: { kind: 'unresolved' } } } -type WireCallView = Extract, { for: 'call' }>['view'] - function call( seq: number, callId: string, - view: WireCallView | null, + name: string, + args: Readonly>, + turn = 1, +): ConversationEventInput { + return rawCall(seq, callId, name, JSON.stringify(args), turn) +} + +function rawCall( + seq: number, + callId: string, + name: string, + argsRaw: string, turn = 1, ): ConversationEventInput { return at( seq, 'tool/call', - { turn, step: 1, callId, name: 'fixture', arguments: '{}' }, - { for: 'call', view: view ?? { card: 'generic', title: 'fixture' } }, + { turn, step: 1, callId, name, arguments: argsRaw }, ) } @@ -151,18 +157,6 @@ function result(seq: number, callId: string, isError = false, turn = 1): Convers }) } -function diff(...paths: string[]): WireCallView { - return { - card: 'diff', title: `Write ${paths[0] ?? ''}`, - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - } -} - -function edit(path: string): WireCallView { - return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] } -} - function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler { const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) value.replaceWindow(entries, hasMore) @@ -189,36 +183,148 @@ describe('produced-file Turn data', () => { expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull() }) - it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => { + it('folds successful first-party mutation paths from their raw arguments', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), - call(2, 'write', diff('out/index.html', 'out/app.css')), + call(2, 'write', 'write', { + file_path: 'out/index.html', path: 'wrong-write.txt', content: '', + }), result(3, 'write'), - call(4, 'edit', edit('notes.md')), + call(4, 'edit', 'edit', { + file_path: 'out/app.css', path: 'wrong-edit.txt', old_string: 'red', new_string: 'blue', + replace_all: false, + }), result(5, 'edit'), - call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }), - result(7, 'read'), - call(8, 'failed', diff('broken.txt')), - result(9, 'failed', true), - call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }), - result(11, 'locationless'), + call(6, 'create', 'str_replace_editor', { + command: 'create', path: 'notes/new.md', file_path: 'wrong-create.txt', file_text: 'new', + }), + result(7, 'create'), + call(8, 'replace', 'str_replace_editor', { + command: 'str_replace', path: 'notes/existing.md', old_str: 'old', new_str: 'new', + }), + result(9, 'replace'), + call(10, 'delete-text', 'str_replace_editor', { + command: 'str_replace', path: 'notes/deleted-text.md', old_str: 'remove me', + }), + result(11, 'delete-text'), + call(12, 'insert', 'str_replace_editor', { + command: 'insert', path: 'notes/inserted.md', insert_line: 1, new_str: 'line', + }), + result(13, 'insert'), ]) expect(producedForClosing(deliverablesOf(value))).toEqual([ - 'out/index.html', 'out/app.css', 'notes.md', + 'out/index.html', + 'out/app.css', + 'notes/new.md', + 'notes/existing.md', + 'notes/deleted-text.md', + 'notes/inserted.md', ]) }) - it('ignores calls without mutation locations, orphan results, and replacement results', () => { - const replacement = result(8, 'replacement') + it.each([ + { caseName: 'write omits content', name: 'write', args: { file_path: 'write.txt' } }, + { caseName: 'write has non-string content', name: 'write', args: { file_path: 'write.txt', content: 1 } }, + { + caseName: 'edit omits old_string', name: 'edit', + args: { file_path: 'edit.txt', new_string: 'new' }, + }, + { + caseName: 'edit has an empty old_string', name: 'edit', + args: { file_path: 'edit.txt', old_string: '', new_string: 'new' }, + }, + { + caseName: 'edit omits new_string', name: 'edit', + args: { file_path: 'edit.txt', old_string: 'old' }, + }, + { + caseName: 'edit does not change the string', name: 'edit', + args: { file_path: 'edit.txt', old_string: 'same', new_string: 'same' }, + }, + { + caseName: 'edit has a non-boolean replace_all', name: 'edit', + args: { file_path: 'edit.txt', old_string: 'old', new_string: 'new', replace_all: 'yes' }, + }, + { + caseName: 'editor create omits file_text', name: 'str_replace_editor', + args: { command: 'create', path: 'create.txt' }, + }, + { + caseName: 'editor create has non-string file_text', name: 'str_replace_editor', + args: { command: 'create', path: 'create.txt', file_text: 1 }, + }, + { + caseName: 'editor replace omits old_str', name: 'str_replace_editor', + args: { command: 'str_replace', path: 'replace.txt', new_str: 'new' }, + }, + { + caseName: 'editor replace has an empty old_str', name: 'str_replace_editor', + args: { command: 'str_replace', path: 'replace.txt', old_str: '' }, + }, + { + caseName: 'editor replace has non-string new_str', name: 'str_replace_editor', + args: { command: 'str_replace', path: 'replace.txt', old_str: 'old', new_str: 1 }, + }, + { + caseName: 'editor insert omits insert_line', name: 'str_replace_editor', + args: { command: 'insert', path: 'insert.txt', new_str: 'new' }, + }, + { + caseName: 'editor insert has a fractional insert_line', name: 'str_replace_editor', + args: { command: 'insert', path: 'insert.txt', insert_line: 1.5, new_str: 'new' }, + }, + { + caseName: 'editor insert has a negative insert_line', name: 'str_replace_editor', + args: { command: 'insert', path: 'insert.txt', insert_line: -1, new_str: 'new' }, + }, + { + caseName: 'editor insert omits new_str', name: 'str_replace_editor', + args: { command: 'insert', path: 'insert.txt', insert_line: 1 }, + }, + ])('ignores a successful result when $caseName', ({ name, args }) => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), - at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }), - result(3, 'no-view'), - call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }), - result(5, 'locationless-edit'), - result(6, 'orphan'), - call(7, 'replacement', diff('replaced.txt')), + call(2, 'malformed', name, args), + result(3, 'malformed'), + ]) + + expect(producedForClosing(deliverablesOf(value))).toEqual([]) + }) + + it('ignores editor views, unsupported tools, failures, interruptions, malformed calls, and orphan results', () => { + const replacement = result(25, 'replacement') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'view', 'str_replace_editor', { command: 'view', path: 'viewed.txt' }), + result(3, 'view'), + call(4, 'read', 'read', { file_path: 'input.txt' }), + result(5, 'read'), + call(6, 'unknown', 'custom_edit', { file_path: 'custom.txt', path: 'custom.txt' }), + result(7, 'unknown'), + call(8, 'failed', 'write', { file_path: 'failed.txt', content: 'x' }), + result(9, 'failed', true), + call(10, 'interrupted', 'edit', { + file_path: 'interrupted.txt', old_string: 'old', new_string: 'new', + }), + rawCall(11, 'invalid-json', 'write', '{'), + result(12, 'invalid-json'), + rawCall(13, 'null-args', 'write', 'null'), + result(14, 'null-args'), + rawCall(15, 'array-args', 'edit', '[]'), + result(16, 'array-args'), + call(17, 'missing-path', 'write', { content: 'x' }), + result(18, 'missing-path'), + call(19, 'blank-path', 'edit', { + file_path: ' ', old_string: 'old', new_string: 'new', + }), + result(20, 'blank-path'), + call(21, 'missing-editor-path', 'str_replace_editor', { command: 'create', file_text: 'x' }), + result(22, 'missing-editor-path'), + result(23, 'orphan'), + call(24, 'replacement', 'str_replace_editor', { + command: 'insert', path: 'replaced.txt', insert_line: 0, new_str: 'new', + }), { ...replacement, event: { @@ -226,7 +332,7 @@ describe('produced-file Turn data', () => { surfaceOp: { op: 'replace', start: 1, end: 1 }, } as ConversationEventInput['event'], }, - at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(26, 'turn/end', { turn: 1, reason: { kind: 'interrupted' } }), ]) expect(producedForClosing(deliverablesOf(value))).toEqual([]) @@ -255,7 +361,7 @@ describe('produced-file Turn data', () => { it('replays a tail page once prepend supplies its missing Turn start', () => { const value = assembler([ - call(10, 'late', diff('history.txt')), + call(10, 'late', 'write', { file_path: 'history.txt', content: 'history' }), result(11, 'late'), ], true) expect(deliverablesOf(value)).toBeUndefined() @@ -268,13 +374,15 @@ describe('produced-file Turn data', () => { it('extends the same Turn data incrementally on live append', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), - call(2, 'first', diff('first.txt')), + call(2, 'first', 'write', { file_path: 'first.txt', content: 'first' }), result(3, 'first'), ]) const first = deliverablesOf(value) expect(producedForClosing(first)).toEqual(['first.txt']) - value.append(call(4, 'second', diff('second.txt'))) + value.append(call(4, 'second', 'edit', { + file_path: 'second.txt', old_string: 'before', new_string: 'after', + })) value.append(result(5, 'second')) value.flush() expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt']) From d9a071340fb7652ea2b1affb38570b8dd9d46272 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:24:31 +0800 Subject: [PATCH 012/138] fix(client): preserve editor running diffs --- ...client-derived-tool-presentation.i18n.yaml | 4 +- ...-08-23-client-derived-tool-presentation.md | 3 ++ ...-23-client-derived-tool-presentation.zh.md | 3 ++ packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../src/client/tool/models/diff-card-model.ts | 31 ++++++++++++-- .../ui-tool/tests/diff-card.client.spec.tsx | 41 +++++++++++++++++++ 8 files changed, 80 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index 8245fb8396..f8e974a4cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-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/architecture/2026-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 3643c003058f2c17c297606357e35fb43f2e5280 -2026-08-23-client-derived-tool-presentation.zh.md: 31d3a9f082b937dd8d8002bfbc30ef85200ba367 +2026-08-23-client-derived-tool-presentation.md: 957d2e6c1a79cb0b0a246066463e6ac960fb3d5b +2026-08-23-client-derived-tool-presentation.zh.md: 5fa1a350963ff74306520bad6a4fc89765c592a7 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 3643c00305..957d2e6c1a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -322,7 +322,10 @@ Standard and persistent providers sharing the same tool name are a special compa |---|---| | running `write` | intended added-only diff from `file_path` and `content` | | running `edit` | intended replacement diff from `file_path`, `old_string`, and `new_string` | +| running `str_replace_editor create` | intended added-only diff from `path` and `file_text` | +| running `str_replace_editor str_replace` | intended replacement diff from `path`, `old_str`, and `new_str` | | successful settled `write`/`edit` | applied contextual hunks from `meta.diffs` | +| settled `str_replace_editor` | Generic, because the tool defines no result presenter | | write create or missing/malformed/empty applied metadata | current argument fallback | | error, malformed arguments, edit with malformed metadata, or Code Dispatch child | Generic | diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 31d3a9f082..5fa1a35096 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -322,7 +322,10 @@ TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复 |---|---| | running `write` | 从 `file_path` 与 `content` 生成 intended added-only diff | | running `edit` | 从 `file_path`、`old_string`、`new_string` 生成 intended replacement diff | +| running `str_replace_editor create` | 从 `path` 与 `file_text` 生成 intended added-only diff | +| running `str_replace_editor str_replace` | 从 `path`、`old_str` 与 `new_str` 生成 intended replacement diff | | settled `write`/`edit` success | 从 `meta.diffs` 生成 applied contextual hunks | +| settled `str_replace_editor` | Generic,因为该工具没有 result presenter | | write create 或 applied metadata 缺失、畸形、为空 | 当前 args fallback | | error、畸形 args、edit 的 metadata 畸形、Code Dispatch child | Generic | diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index a9bd41853a..3f895f0eb9 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 16fd06332d24b265ac7d6b7b000870686262b9c5 -README.zh.md: a21acab8ed6e7b52770a9ee6a63219c11b949c3d +README.md: 2db7d716dc80fbf40a953b217810fb8674e2e98f +README.zh.md: 79ed5befe751b329984c1320144921339fdf3d3f diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 16fd06332d..2db7d716dc 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -30,7 +30,7 @@ ctx.slots.inject('tool.call.toolview', () => The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; the field is absent on a root Session call, so row and Details card models preserve the generic flattened form for descendants without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service. -This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`. +This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall`/`presentResult` values never enter the Client. `ui-skill` demonstrates a business-owned registration for `skill`. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index a21acab8ed..79ed5befe7 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -30,7 +30,7 @@ ctx.slots.inject('tool.call.toolview', () => owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。Code Dispatch block 保留其事件已有的 `parentCallId`;root Session call 没有该字段,因此 row 与 Details card model 无需另一项 placement 标志即可让 descendant 保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。 -本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall`/`presentResult` 值不会进入 Client。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 和 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) Agent Note 负责。 diff --git a/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts b/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts index eed7826594..03eaf04a3a 100644 --- a/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts @@ -1,4 +1,4 @@ -/** Pure diff-card derivation from raw write/edit calls and result metadata. @module */ +/** Pure diff-card derivation from raw file-mutation calls and result metadata. @module */ import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallBlock } from './tool-call-model.ts' import { parsedToolCall, validEscalationFields } from './raw-tool-call.ts' @@ -47,11 +47,31 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null { return out } -type IntendedDiff = { tool: 'write' | 'edit'; diff: DiffHunk } +type IntendedDiff = { tool: 'write' | 'edit' | 'str_replace_editor'; diff: DiffHunk } function intendedDiff(block: ToolCallBlock): IntendedDiff | null { const parsed = parsedToolCall(block) if (parsed === null) return null + if (parsed.name === 'str_replace_editor') { + const { command, path, file_text: fileText, old_str: oldText, new_str: newText } = parsed.args + if (typeof path !== 'string' || path.trim() === '') return null + if (command === 'create') { + if (fileText !== undefined && typeof fileText !== 'string') return null + return { + tool: 'str_replace_editor', + diff: { path, oldText: null, newText: fileText ?? '' }, + } + } + if (command === 'str_replace') { + if (oldText !== undefined && typeof oldText !== 'string') return null + if (newText !== undefined && typeof newText !== 'string') return null + return { + tool: 'str_replace_editor', + diff: { path, oldText: oldText ?? null, newText: newText ?? '' }, + } + } + return null + } const { file_path: path } = parsed.args if (typeof path !== 'string' || path.trim() === '') return null if (!validEscalationFields(parsed.args)) return null @@ -77,9 +97,11 @@ function appliedDiffs(meta: unknown): DiffHunk[] | 'empty' | null { } /** - * Derive intended running or applied settled diffs for a root write/edit call. + * Derive running diffs for root write/edit and `str_replace_editor` + * create/replace calls, plus applied settled diffs for root write/edit calls. * A successful write with valid empty metadata uses its argument-derived - * whole-file diff, matching create and identical-overwrite presentation. + * whole-file diff, matching create and identical-overwrite presentation; + * `str_replace_editor` settles through Generic because it has no result view. * @param block - running or settled Tool block. * @returns the diff-card props, or null for the generic path. */ @@ -88,6 +110,7 @@ export function diffCardModel(block: ToolCallBlock): DiffCardModel | null { const intended = intendedDiff(block) if (intended === null) return null if (!('kind' in block)) return { card: { diffs: [intended.diff] } } + if (intended.tool === 'str_replace_editor') return null if (block.isError) return null const applied = appliedDiffs(block.meta) if (applied === null || applied === 'empty') { diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 3c113b3d3e..8a263f2983 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -63,6 +63,47 @@ describe('diffCardModel', () => { }) }) + it.each([ + { + command: 'create', + args: { command: 'create', path: 'notes/new.txt', file_text: 'new file\n' }, + diff: { path: 'notes/new.txt', oldText: null, newText: 'new file\n' }, + }, + { + command: 'str_replace', + args: { command: 'str_replace', path: 'notes/demo.txt', old_str: 'old', new_str: 'new' }, + diff: { path: 'notes/demo.txt', oldText: 'old', newText: 'new' }, + }, + ])('preserves the running str_replace_editor $command diff', ({ args, diff }) => { + expect(diffCardModel(running({ + name: 'str_replace_editor', + argsRaw: JSON.stringify(args), + }))).toEqual({ card: { diffs: [diff] } }) + }) + + it('preserves str_replace_editor defaults and its settled Generic result', () => { + const argsRaw = JSON.stringify({ command: 'str_replace', path: 'notes/demo.txt' }) + expect(diffCardModel(running({ name: 'str_replace_editor', argsRaw }))).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: '' }] }, + }) + expect(diffCardModel(settled({ + call: { name: 'str_replace_editor', argsRaw }, + meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'old', newText: 'new' }] }, + }))).toBeNull() + }) + + it('keeps unsupported or malformed str_replace_editor calls generic', () => { + const editor = (args: Record) => running({ + name: 'str_replace_editor', argsRaw: JSON.stringify(args), + }) + expect(diffCardModel(editor({ command: 'view', path: 'notes/demo.txt' }))).toBeNull() + expect(diffCardModel(editor({ command: 'insert', path: 'notes/demo.txt', new_str: 'x' }))).toBeNull() + expect(diffCardModel(editor({ command: 'create', path: '', file_text: 'x' }))).toBeNull() + expect(diffCardModel(editor({ command: 'create', path: 'notes/demo.txt', file_text: 1 }))).toBeNull() + expect(diffCardModel(editor({ command: 'str_replace', path: 'notes/demo.txt', old_str: 1 }))).toBeNull() + expect(diffCardModel(editor({ command: 'str_replace', path: 'notes/demo.txt', new_str: 1 }))).toBeNull() + }) + it('derives a settled card from result metadata, which replaces the intended diff', () => { expect(diffCardModel(settled({ meta: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }, From bfc145cc7ce73c0542fb5046d319a522184dc22e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:24:59 +0800 Subject: [PATCH 013/138] docs(tools): link terminal presentation markers --- .../src/client/tool/models/terminal-card-model.ts | 13 +++++++++++-- packages/shell/shell/src/render.ts | 5 +++-- packages/terminal/tool-terminal/src/index.ts | 2 ++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index a34a56dfc7..548a024562 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -156,8 +156,9 @@ function shellCall(name: string, args: Record): ShellCall | nul if (background !== undefined && typeof background !== 'boolean') return null if (!validEscalationFields(args)) return null if (description === undefined) { - // Persistent shell providers consume only `command`; parameter roots are - // open, so unrelated fields do not change their running-card behavior. + // Standard dsh-tool-bash and dsh-tool-pwsh schemas require `description`; + // persistent shell providers omit it. Their parameter roots stay open, so + // unrelated fields do not change their running-card behavior. return { command, description: undefined, workdir: undefined, persistent: true, background: false } } if (typeof description !== 'string' || description.trim() === '') return null @@ -183,12 +184,20 @@ function terminalSendCall(name: string, args: Record): Terminal if (submit !== undefined && typeof submit !== 'boolean') return null if (background !== undefined && typeof background !== 'boolean') return null return { + // Keep this visible fallback aligned with dsh-tool-terminal's + // `terminal_send.presentCall` implementation. command: text || '(send input)', description: `Terminal ${sessionId}`, background: background === true, } } +/** + * Parse the marker literals owned by `@deepseek-ai/dsh-shell/render` without + * importing that Host-only package into the Client dependency graph. + * @param text - rendered shell result text. + * @returns output with a trailing exit-code or signal marker extracted. + */ function parseExitStatus(text: string): { output: string; exitCode?: number; signal?: string } { const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) if (signal?.[1] !== undefined) return { output: text.slice(0, signal.index), signal: signal[1] } diff --git a/packages/shell/shell/src/render.ts b/packages/shell/shell/src/render.ts index 3b077d64f1..8fc924ef63 100644 --- a/packages/shell/shell/src/render.ts +++ b/packages/shell/shell/src/render.ts @@ -1,7 +1,8 @@ /** * Shared rendering helpers for the shell tools (`dsh-tool-bash`, - * `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers - * emit and the presentation layer parses back. + * `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers emit, + * Host `presentResult` implementations parse here, and the Web terminal card + * model mirrors without importing Host code. * @module @deepseek-ai/dsh-shell/render */ diff --git a/packages/terminal/tool-terminal/src/index.ts b/packages/terminal/tool-terminal/src/index.ts index e0ed86a289..0ab37fdc31 100644 --- a/packages/terminal/tool-terminal/src/index.ts +++ b/packages/terminal/tool-terminal/src/index.ts @@ -284,6 +284,8 @@ export function apply(ctx: Context, config: Config = {}): void { if (parsed.run_in_background === true) { return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text } } + // Keep this visible fallback aligned with dsh-client-ui-tool's + // terminal-card model, which cannot import this Host package. return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` } }, presentResult(args, result) { From 1dd6bf1973a4a4789272b461bd2eb1c716b39b9b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:53:04 +0800 Subject: [PATCH 014/138] fix(client): localize terminal send presentation --- ...client-derived-tool-presentation.i18n.yaml | 4 +- ...-08-23-client-derived-tool-presentation.md | 2 + ...-23-client-derived-tool-presentation.zh.md | 2 + .../ui-conversation/src/client/locales.ts | 6 ++ .../ui-tool/src/client/tool/ToolDetails.tsx | 9 +- .../src/client/tool/components/ToolRow.tsx | 10 ++- .../client/tool/models/terminal-card-model.ts | 87 +++++++++++++------ .../client/tool/toolviews/GenericToolCard.tsx | 2 +- .../src/client/tool/toolviews/bash-sample.tsx | 9 +- .../tests/terminal-card.client.spec.tsx | 71 ++++++++++++--- packages/terminal/tool-terminal/src/index.ts | 4 +- 11 files changed, 152 insertions(+), 54 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index f8e974a4cd..bfe72f19a4 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-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/architecture/2026-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 957d2e6c1a79cb0b0a246066463e6ac960fb3d5b -2026-08-23-client-derived-tool-presentation.zh.md: 5fa1a350963ff74306520bad6a4fc89765c592a7 +2026-08-23-client-derived-tool-presentation.md: 77fb48552625c1b22908225b0116eeba14c6046c +2026-08-23-client-derived-tool-presentation.zh.md: 50cc447f9f4ca2e946de10d2df31ca18382d95ef diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 957d2e6c1a..77fb485526 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -312,6 +312,8 @@ Standard shell results continue parsing trailing `[exit code: N]` and `[killed b Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots. +For `terminal_send`, non-empty input and the session id remain verbatim tool data; the empty-input fallback and session label resolve through the render site's conversation locale. + Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation. `TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 5fa1a35096..50cc447f9f 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -312,6 +312,8 @@ Client terminal model 从工具名称、调用参数、结果 content、error、 调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。 +对于 `terminal_send`,非空 input 与 session id 保持为逐字工具数据;空 input fallback 与 session label 通过 render site 的 conversation locale 解析。 + 同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。 TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index fc0d361ec2..3ab8410590 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -145,6 +145,8 @@ export const zh = { 'terminal.collapseAria': '收起输出', 'terminal.expandAria': '展开其余 {n} 行输出', 'terminal.expandRest': '… 其余 {n} 行', + 'terminal.sendInput': '(发送输入)', + 'terminal.session': '终端 {sessionId}', } satisfies Record /** The conversation namespace key union. */ @@ -288,4 +290,8 @@ export const en = { 'terminal.collapseAria': 'Collapse output', 'terminal.expandAria': 'Expand the remaining {n} output lines', 'terminal.expandRest': '… {n} more lines', + // The Host terminal_send presenter has no locale seat; keep its fallbacks + // aligned with these English values. + 'terminal.sendInput': '(send input)', + 'terminal.session': 'Terminal {sessionId}', } satisfies Record diff --git a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx index 42ddd41824..c946f0ee7d 100644 --- a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx @@ -4,7 +4,9 @@ import type { ToolDetailsProps } from '../contract/slots.ts' import { diffCardModel } from './models/diff-card-model.ts' import { readCardModel } from './models/read-card-model.ts' import { searchCardModel } from './models/search-card-model.ts' -import { terminalBlockLabels, terminalCardModel } from './models/terminal-card-model.ts' +import { + localizeTerminalCardModel, terminalBlockLabels, terminalCardModel, +} from './models/terminal-card-model.ts' import { diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, } from './models/primitive-labels.ts' @@ -22,8 +24,9 @@ export function ToolDetails({ block, cwd, useHostDescription, t, }: Pick) { const home = useHostDescription(description => description?.home) - const terminal = terminalCardModel(block, cwd) - if (terminal !== null) { + const terminalModel = terminalCardModel(block, cwd) + if (terminalModel !== null) { + const terminal = localizeTerminalCardModel(terminalModel, t) return ( <> {terminal.description !== undefined ? ( diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 96b3213a04..8102965a6d 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -7,7 +7,9 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts' import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts' import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts' -import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts' +import { + localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel, +} from '../models/terminal-card-model.ts' import { diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, } from '../models/primitive-labels.ts' @@ -106,7 +108,9 @@ export function ToolRow({ const readLabels = useMemo(() => readBlockLabels(t), [t]) const searchLabels = useMemo(() => searchBlockLabels(t), [t]) const webLabels = useMemo(() => webBlockLabels(t), [t]) - const terminalBody = terminal ?? null + const terminalBody = terminal === undefined || terminal === null + ? null + : localizeTerminalCardModel(terminal, t) const diffBody = diff ?? null const readBody = read ?? null const searchBody = search ?? null @@ -118,7 +122,7 @@ export function ToolRow({ const status = stateStatus(state, t) // A failure must replace, not supplement, the normal summary. const failureLine = state === 'error' ? errorSummary ?? null : null - const summaryText = failureLine ?? summary + const summaryText = failureLine ?? terminalBody?.description ?? summary const suffix = failureLine === null ? summarySuffix ?? null : null const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null const toggleExpand = () => { diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index 548a024562..1956fe232e 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -37,16 +37,48 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo */ export interface TerminalCardModel { /** - * The props {@link TerminalBlock} draws. Held as a nested object so a render - * site spreads exactly the primitive's own surface and can never leak a - * neighbouring field into it. + * The locale-neutral props {@link TerminalBlock} draws. The render site adds + * `command` after resolving {@link copy} through its locale seat. */ - card: Pick + card: Pick /** - * The model-authored call description rendered above the card. Absent for - * persistent shells, whose parameter set has no description. + * Verbatim Tool data or semantic `terminal_send` data. Product copy stays + * unresolved until a render site supplies its locale seat. */ - description: string | undefined + copy: + | { readonly kind: 'shell'; readonly command: string; readonly description: string | undefined } + | { readonly kind: 'terminal-send'; readonly text: string; readonly sessionId: string } +} + +interface LocalizedTerminalCardModel { + readonly card: Pick + readonly description: string | undefined +} + +/** + * Resolve locale-owned `terminal_send` copy while preserving Tool-authored + * shell commands and descriptions verbatim. + * @param model - locale-neutral terminal card data. + * @param t - the render site's conversation locale seat. + * @returns terminal props and description ready for rendering. + */ +export function localizeTerminalCardModel( + model: TerminalCardModel, + t: TranslateNS<'conversation'>, +): LocalizedTerminalCardModel { + if (model.copy.kind === 'shell') { + return { + card: { command: model.copy.command, ...model.card }, + description: model.copy.description, + } + } + return { + card: { + command: model.copy.text === '' ? t('terminal.sendInput') : model.copy.text, + ...model.card, + }, + description: t('terminal.session', { sessionId: model.copy.sessionId }), + } } /** @@ -140,6 +172,7 @@ function collapse(body: string, rooted: boolean, separator = '/'): string { } interface ShellCall { + kind: 'shell' command: string description: string | undefined workdir: string | undefined @@ -159,10 +192,11 @@ function shellCall(name: string, args: Record): ShellCall | nul // Standard dsh-tool-bash and dsh-tool-pwsh schemas require `description`; // persistent shell providers omit it. Their parameter roots stay open, so // unrelated fields do not change their running-card behavior. - return { command, description: undefined, workdir: undefined, persistent: true, background: false } + return { kind: 'shell', command, description: undefined, workdir: undefined, persistent: true, background: false } } if (typeof description !== 'string' || description.trim() === '') return null return { + kind: 'shell', command, description, workdir, @@ -172,8 +206,9 @@ function shellCall(name: string, args: Record): ShellCall | nul } interface TerminalSendCall { - command: string - description: string + kind: 'terminal-send' + text: string + sessionId: string background: boolean } @@ -184,10 +219,9 @@ function terminalSendCall(name: string, args: Record): Terminal if (submit !== undefined && typeof submit !== 'boolean') return null if (background !== undefined && typeof background !== 'boolean') return null return { - // Keep this visible fallback aligned with dsh-tool-terminal's - // `terminal_send.presentCall` implementation. - command: text || '(send input)', - description: `Terminal ${sessionId}`, + kind: 'terminal-send', + text, + sessionId, background: background === true, } } @@ -213,7 +247,7 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig * the generic path. * @param block - running or settled Tool block. * @param sessionCwd - session workspace root used to resolve workdir. - * @returns the terminal-card props, or null for the generic path. + * @returns locale-neutral terminal-card data, or null for the generic path. */ export function terminalCardModel( block: ToolCallBlock, @@ -222,19 +256,17 @@ export function terminalCardModel( if (block.parentCallId !== undefined) return null const parsed = parsedToolCall(block) if (parsed === null) return null - const shell = shellCall(parsed.name, parsed.args) - const send = terminalSendCall(parsed.name, parsed.args) - if (shell === null && send === null) return null - if (shell?.background === true || send?.background === true) return null + const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args) + if (call === null || call.background) return null - const command = shell?.command ?? send?.command ?? '' - const description = shell?.description ?? send?.description - const cwd = resolveTerminalCwd(shell?.workdir, sessionCwd) + const copy: TerminalCardModel['copy'] = call.kind === 'shell' + ? { kind: 'shell', command: call.command, description: call.description } + : { kind: 'terminal-send', text: call.text, sessionId: call.sessionId } + const cwd = resolveTerminalCwd(call.kind === 'shell' ? call.workdir : undefined, sessionCwd) if (!('kind' in block)) { return { - description, + copy, card: { - command, cwd, output: undefined, exitCode: undefined, @@ -243,14 +275,13 @@ export function terminalCardModel( }, } } - if (block.isError || shell?.persistent === true) return null + if (block.isError || (call.kind === 'shell' && call.persistent)) return null const output = singleResultText(block) if (output === undefined) return null - const status = shell === null ? { output } : parseExitStatus(output) + const status = call.kind === 'terminal-send' ? { output } : parseExitStatus(output) return { - description, + copy, card: { - command, cwd, output: status.output, exitCode: status.exitCode, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index 0fe0883e0a..41b13e20d6 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -47,7 +47,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, toolName={toolName} icon={VARIANT_ICONS[model.variant]} title={t(model.titleKey)} - summary={terminal?.description ?? model.summary} + summary={model.summary} // Single-file tools never expose an args body — the path link is the only // args interaction. A card is not an args body: a read/write/edit row is // single-file AND carries a card, so the card expands under the path link. diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index eaba61f015..321b156078 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -6,7 +6,9 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' -import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../models/terminal-card-model.ts' +import { + localizeTerminalCardModel, terminalBlockLabels, terminalCardModel, terminalFailed, +} from '../models/terminal-card-model.ts' import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts' import { CONVERSATION_NS as NS } from '../../locale.ts' import css from './bash-sample.module.css' @@ -38,10 +40,11 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: // An omitted shell workdir is the session workspace; relative values resolve // against it before reaching the terminal primitive. const cwd = useSessions(list => list.byId[sessionId]?.cwd) - const terminal = terminalCardModel(block, cwd) + const terminalModel = terminalCardModel(block, cwd) + const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t) // A failing exit status is the terminal card's own error signal (the call // itself settles isError:false), surfaced as the row's red state dot. - const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal) + const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel) ? 'error' : model.state const status = stateStatus(state, t) diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 2543f7bfe7..8d57084ab3 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -12,19 +12,23 @@ import type { import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts' +import { + localizeTerminalCardModel, terminalCardModel, terminalFailed, +} from '../src/client/tool/models/terminal-card-model.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' -import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' type BashRowProps = Parameters[0] const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) +const enT: GenericToolCardProps['t'] = makeTranslate(en, commonEn) const chatT = makeTranslate(chatZh, commonZh) afterEach(cleanup) @@ -65,9 +69,9 @@ const settled = (over?: Partial): ToolResultNode => ({ describe('terminalCardModel', () => { it('derives a running standard-shell card from raw arguments', () => { expect(terminalCardModel(running({ argsRaw: shellArgs({ workdir: '/projects/app' }) }))).toEqual({ - description: 'List files', + copy: { kind: 'shell', command: 'ls -la', description: 'List files' }, card: { - command: 'ls -la', cwd: '/projects/app', output: undefined, + cwd: '/projects/app', output: undefined, exitCode: undefined, signal: undefined, running: true, }, }) @@ -78,9 +82,9 @@ describe('terminalCardModel', () => { call: { name: 'bash', argsRaw: shellArgs({ workdir: '/projects/app' }) }, content: [{ type: 'text', text: 'boom\n[exit code: 2]' }], }))).toEqual({ - description: 'List files', + copy: { kind: 'shell', command: 'ls -la', description: 'List files' }, card: { - command: 'ls -la', cwd: '/projects/app', output: 'boom', + cwd: '/projects/app', output: 'boom', exitCode: 2, signal: undefined, running: false, }, }) @@ -163,10 +167,13 @@ describe('terminalCardModel', () => { const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: 'make' }) const run = running({ name: 'terminal_send', argsRaw }) expect(terminalCardModel(run, '/w/app')).toMatchObject({ - description: 'Terminal pty-3', card: { command: 'make', cwd: '/w/app', running: true }, + copy: { kind: 'terminal-send', text: 'make', sessionId: 'pty-3' }, + card: { cwd: '/w/app', running: true }, }) const done = settled({ call: { name: 'terminal_send', argsRaw }, content: [{ type: 'text', text: 'ok' }] }) - expect(terminalCardModel(done)?.card).toMatchObject({ command: 'make', output: 'ok', running: false }) + expect(localizeTerminalCardModel(terminalCardModel(done)!, enT)).toMatchObject({ + description: 'Terminal pty-3', card: { command: 'make', output: 'ok', running: false }, + }) expect(terminalCardModel(settled({ call: { name: 'terminal_send', argsRaw: JSON.stringify({ sessionId: 'pty-3', text: 'make', run_in_background: true }) }, }))).toBeNull() @@ -176,10 +183,10 @@ describe('terminalCardModel', () => { it('preserves persistent-shell running cards and settled generic output', () => { const persistent = JSON.stringify({ command: 'pwd' }) expect(terminalCardModel(running({ argsRaw: persistent }))).toMatchObject({ - description: undefined, card: { command: 'pwd', running: true }, + copy: { kind: 'shell', command: 'pwd', description: undefined }, card: { running: true }, }) expect(terminalCardModel(running({ name: 'pwsh', argsRaw: persistent }))).toMatchObject({ - description: undefined, card: { command: 'pwd', running: true }, + copy: { kind: 'shell', command: 'pwd', description: undefined }, card: { running: true }, }) expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: persistent } }))).toBeNull() expect(terminalCardModel(settled({ call: { name: 'pwsh', argsRaw: persistent } }))).toBeNull() @@ -190,8 +197,22 @@ describe('terminalCardModel', () => { call: { name: 'pwsh', argsRaw: ARGS }, content: [{ type: 'text', text: 'failed\n[exit code: 3]' }], }))).toMatchObject({ - description: 'List files', - card: { command: 'ls -la', output: 'failed', exitCode: 3, running: false }, + copy: { kind: 'shell', command: 'ls -la', description: 'List files' }, + card: { output: 'failed', exitCode: 3, running: false }, + }) + }) + + it('keeps terminal_send copy semantic until the render locale is known', () => { + const model = terminalCardModel(running({ + name: 'terminal_send', + argsRaw: JSON.stringify({ sessionId: 'pty-3', text: '' }), + }))! + expect(model.copy).toEqual({ kind: 'terminal-send', text: '', sessionId: 'pty-3' }) + expect(localizeTerminalCardModel(model, t)).toMatchObject({ + description: '终端 pty-3', card: { command: '(发送输入)' }, + }) + expect(localizeTerminalCardModel(model, enT)).toMatchObject({ + description: 'Terminal pty-3', card: { command: '(send input)' }, }) }) @@ -323,6 +344,20 @@ describe('chat row terminal body', () => { expect(runStateOf(view.container)).toBe('ongoing') }) + it.each([ + { locale: 'zh', translate: t, description: '终端 pty-3', command: '(发送输入)' }, + { locale: 'en', translate: enT, description: 'Terminal pty-3', command: '(send input)' }, + ])('renders terminal_send copy through the $locale locale', ({ translate, description, command }) => { + const block = running({ + name: 'terminal_send', + argsRaw: JSON.stringify({ sessionId: 'pty-3', text: '' }), + }) + const view = render() + expect(view.getByText(description)).toBeTruthy() + toggleRow(view) + expect(view.getByText(command)).toBeTruthy() + }) + it('a non-terminal call keeps the args-JSON text body', () => { const view = render( { expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() }) + it('localizes terminal_send copy in Details', () => { + const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: '' }) + const view = mount(snapshot({ + nodes: [settled({ + call: { name: 'terminal_send', argsRaw }, + content: [{ type: 'text', text: 'ok' }], + })], + }), { ...target, toolName: 'terminal_send' }) + expect(view.getByText('终端 pty-3')).toBeTruthy() + expect(view.getByText('(发送输入)')).toBeTruthy() + }) + it('resolves the prompt cwd against the session workspace', () => { const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app') // No workdir in the call args: the prompt label is the workspace basename. diff --git a/packages/terminal/tool-terminal/src/index.ts b/packages/terminal/tool-terminal/src/index.ts index 0ab37fdc31..b08938d5e9 100644 --- a/packages/terminal/tool-terminal/src/index.ts +++ b/packages/terminal/tool-terminal/src/index.ts @@ -284,8 +284,8 @@ export function apply(ctx: Context, config: Config = {}): void { if (parsed.run_in_background === true) { return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text } } - // Keep this visible fallback aligned with dsh-client-ui-tool's - // terminal-card model, which cannot import this Host package. + // Keep these Host-only fallbacks aligned with the conversation locale + // keys `terminal.sendInput` and `terminal.session` used by Web. return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` } }, presentResult(args, result) { From 8fbd1650a32772692320aaf31bee372e63c5432c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:53:38 +0800 Subject: [PATCH 015/138] docs(session): record cold projection composition rule --- .../2026-08-23-client-derived-tool-presentation.i18n.yaml | 4 ++-- .../2026-08-23-client-derived-tool-presentation.md | 4 ++++ .../2026-08-23-client-derived-tool-presentation.zh.md | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index bfe72f19a4..dc2902d1ca 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-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/architecture/2026-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 77fb48552625c1b22908225b0116eeba14c6046c -2026-08-23-client-derived-tool-presentation.zh.md: 50cc447f9f4ca2e946de10d2df31ca18382d95ef +2026-08-23-client-derived-tool-presentation.md: 5598c1fbc6073a71f63a20274cd5a791b6b5e6b3 +2026-08-23-client-derived-tool-presentation.zh.md: 5a87433377af58b1ca9d378b76393dada4ca4a1e diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 77fb485526..5598c1fbc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -654,6 +654,10 @@ A raw event records the tool name but not the specific ToolDefinition. The Clien Old Sessions may lack fields, and manually edited logs may contain malformed values. Each Client model must narrow locally and cannot pass unknown arrays or objects directly into UI primitives. +### Preset-owned projection availability + +History does not compensate for projection units absent from the current composition. A preset-owned unit that must remain visible across a cold read requires the shared Session preparation/projection composition to make its definition available before restore; history must not regain a preset-mount or presenter setup branch. + ### Two targets must stay synchronized Chat and Trajectory have separate Tool Definitions and both carry the raw fields. Card derivation remains only in `ui-tool` and cannot be copied into either Definition. diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 50cc447f9f..5a87433377 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -654,6 +654,10 @@ raw event 只记录 tool name,不记录具体 ToolDefinition。Client 使用 旧 Session 可能缺字段,手工修改日志可能带畸形值。每个 Client model 必须局部收窄,不能把未知数组或对象直接传给 UI primitive。 +### preset-owned projection 可用性 + +history 不为当前组合中缺失的 projection unit 补偿。需要在冷读中保持可见的 preset-owned unit,必须由共享的 Session preparation/projection 组合在 restore 前提供其定义;history 不得重新增加 preset mount 或 presenter setup 分支。 + ### 双 target 同步 Chat 与 Trajectory 各有独立 Tool Definition,两者都携带 raw fields;card derivation 只能留在 `ui-tool`,不能复制进两个 Definition。 From 34a3097317a703e549fa44b1897567ee29049bbd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 10:29:18 +0800 Subject: [PATCH 016/138] fix(preview): point the config-tree declaration at the plugin-bundled presets The worker-preview pack landed on master declaring dsh.configTrees against apps/cli/config/agent-presets, which this branch moved into packages/preset/agent-presets/presets. The VFS mount and the worker-side roster patch keep their paths; only the source directory follows the move. --- apps/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index f572b2b80b..49f7fc84d1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,7 +19,7 @@ ], "dsh": { "configTrees": [ - { "mount": "config/agent-presets", "path": "config/agent-presets", "scanRoster": true } + { "mount": "config/agent-presets", "path": "../../packages/preset/agent-presets/presets", "scanRoster": true } ] }, "license": "MIT", From 8fe9af8db9baf02821804a08b83a6d62fb5f357b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:35:25 +0800 Subject: [PATCH 017/138] feat(webworker): support fs watches and confinement --- .../2026-08-20-webworker-node-face.i18n.yaml | 4 +- .../2026-08-20-webworker-node-face.md | 10 +- .../2026-08-20-webworker-node-face.zh.md | 10 +- ...webworker-vfs-watch-and-landlock.i18n.yaml | 6 + ...-08-23-webworker-vfs-watch-and-landlock.md | 80 +++ ...-23-webworker-vfs-watch-and-landlock.zh.md | 80 +++ THIRD_PARTY_NOTICES.md | 2 + apps/web/tests/preview-boot.e2e.ts | 91 +++- .../webworker-packer/README.i18n.yaml | 4 +- .../experimental/webworker-packer/README.md | 2 +- .../webworker-packer/README.zh.md | 2 +- .../webworker-packer/src/repository.ts | 8 +- .../tests/image-loadable.spec.ts | 49 +- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 8 +- .../webworker-runtime/README.zh.md | 8 +- .../webworker-runtime/package.json | 11 +- .../webworker-runtime/src/module-proxies.ts | 4 +- .../implemented/child_process.ts | 106 +++- .../builtin_modules/implemented/fs-watch.ts | 419 +++++++++++++++ .../node/builtin_modules/implemented/fs.ts | 345 ++++++++++-- .../implemented/fs/promises.ts | 2 +- .../builtin_modules/implemented/stream.ts | 82 +++ .../src/node/builtin_modules/mock/stream.ts | 38 -- .../webworker-runtime/src/node/builtins.ts | 6 +- .../src/node/external_packages/chokidar.ts | 68 --- .../node-addon-landlock-run.ts | 31 -- .../external_packages/replaced-externals.ts | 2 - .../webworker-runtime/src/shell/fs-access.ts | 6 +- .../src/shell/process/landlock.ts | 188 +++++++ .../src/shell/process/virtual-executables.ts | 61 +++ .../webworker-runtime/src/storage/active.ts | 8 +- .../webworker-runtime/src/storage/memory.ts | 192 +++++-- .../webworker-runtime/src/storage/types.ts | 98 ++++ .../tests/node/child-process.spec.ts | 185 +++++++ .../tests/node/chokidar.spec.ts | 210 ++++++++ .../tests/node/fs-watch-stream.spec.ts | 507 ++++++++++++++++++ .../webworker-runtime/tests/node/fs.spec.ts | 16 +- .../tests/node/node-stubs.spec.ts | 44 +- .../tests/node/sandbox-stack.spec.ts | 98 ++++ .../tests/storage/memory-vfs.spec.ts | 119 +++- pnpm-lock.yaml | 73 +++ 42 files changed, 2940 insertions(+), 347 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts delete mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts delete mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts delete mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts create mode 100644 packages/experimental/webworker-runtime/src/shell/process/landlock.ts create mode 100644 packages/experimental/webworker-runtime/src/shell/process/virtual-executables.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml index 4b5a98a8d5..47269a11c8 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.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-20-webworker-node-face.md -2026-08-20-webworker-node-face.md: 08119cce96eff244f8e9ada3462ce5d35c1b538d -2026-08-20-webworker-node-face.zh.md: 573c0be055d066d2d6d0db11a2476ba528517727 +2026-08-20-webworker-node-face.md: 6e69af83354f1139a03d047a700e84e7e918a013 +2026-08-20-webworker-node-face.zh.md: 96a60e459372828273b3a4d1330a7b7eb8f2994f diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md index 08119cce96..6e69af8335 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md @@ -6,21 +6,21 @@ English | [中文](2026-08-20-webworker-node-face.zh.md) ## Problem -The worker runs the web profile's Cordis configuration byte for byte — no worker-specific rows — so a browser's missing platform must be replaced at the module layer, where a proxied module keeps its identity and changes its implementation. That covers three fronts: the Node builtins the tree imports, the filesystem those builtins answer from, and a process layer for the bash tool, which mounted, advertised itself to the model, and then failed on every call while `node:child_process` was a structural stub. +The worker runs the web profile's Cordis configuration byte for byte — no worker-specific rows — so a browser's missing platform must be replaced at the module layer, where a proxied module keeps its identity and changes its implementation. That covers three fronts: the Node builtins the tree imports, the filesystem those builtins answer from, and a process layer for the bash tool. A structural `node:child_process` stub would let that tool mount and advertise itself to the model while every call fails. ## Decision **Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. The worker installs the `process` global itself and fills it into the table at assembly. -**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. The hunt that produced this also fixed the silence around it: cordis's logger verbosity counts UP, so an exporter that declares no level drops every warning — `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts. +**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning. -**Shell.** `node:child_process` is a real implementation over the VFS. The grammar is bought — `@yarnpkg/parsers`' `parseShell` — and the evaluator and command table are owned, because every candidate interpreter brings its own filesystem: pipelines are strings handed along, and each program is a function over the VFS. The table is the machine's whole `/bin`; an absent name reports `command not found` (127). Each `spawn` starts a child Web Worker from this same bundle, its first frame declaring the shell-process role, so the termination ladder is real: `SIGTERM` asks at the next command boundary, `SIGKILL` terminates the worker mid-loop — the preemption an in-thread interpreter can never have. The filesystem face is asynchronous end to end (child frames to the host VFS); `execSync`, `execFileSync`, and `fork` refuse, and `node-pty` stays a stub. +**Shell.** `node:child_process` is a real implementation over the VFS. The grammar is bought — `@yarnpkg/parsers`' `parseShell` — and the evaluator and command table are owned, because every candidate interpreter brings its own filesystem: pipelines are strings handed along, and each program is a function over the VFS. Ordinary commands resolve from that table; native-package protocols may contribute Worker-owned virtual executable wrappers through the [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md). A name in neither set reports `ENOENT` at direct spawn or `command not found` (127) inside shell source. Each `spawn` starts a child Web Worker from this same bundle, its first frame declaring the shell-process role, so the termination ladder is real: `SIGTERM` asks at the next command boundary, `SIGKILL` terminates the worker mid-loop — the preemption an in-thread interpreter can never have. The filesystem face is asynchronous end to end (child frames to the host VFS); `execSync`, `execFileSync`, and `fork` refuse, and `node-pty` stays a stub. ## Alternatives considered **Replacing `dsh-subprocess-local` or the bash executor.** The first would let the proxy table replace a workspace package against its own classification and invert the layering; the second trips `dsh-permission-presets`' boot-time `sandboxMode` validation and drops tested timeout/output behavior. -**`@yarnpkg/shell`, WASM shells, WebContainer.** The matching interpreter is built on real Node streams (~1.5 MB closure to own); WASM was removed from this deployment by decision and WASI has no `fork`; all of them arrive with their own filesystem, the one part that cannot be reused. +**`@yarnpkg/shell`, WASM shells, WebContainer.** The matching interpreter is built on real Node streams (~1.5 MB closure to own); this deployment excludes WASM and WASI has no `fork`; all of them arrive with their own filesystem, the one part that cannot be reused. **`SharedArrayBuffer` + `Atomics.wait` for a synchronous child filesystem.** Measured on the deployment target: without COOP/COEP headers `SharedArrayBuffer` is not defined, and GitHub Pages cannot set response headers. The asynchronous face is a superset; a SAB backend can slot under it later without touching a program. @@ -28,7 +28,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work ## Consequences -- Sandbox modes other than `danger-full-access` fail loud: `SandboxEnforcement` has no "nothing was enforced" value and a browser has no kernel, so `ctx.sandbox.confine` fails closed and the command never starts. Real enforcement at the VFS frame gate is a designed follow-up, not this note. +- `read-only` and `workspace-write` interpret the native Landlock launcher protocol and enforce per-process grants at the VFS frame gate; `danger-full-access` keeps the direct process path. The [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md) owns the narrower meaning of `full` in this execution world. - The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`. - Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts. - The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md index 573c0be055..96a60e4593 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md @@ -6,21 +6,21 @@ ## 问题 -worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属行——因此浏览器缺失的平台必须在模块层被替换:被代理的模块保持身份、更换实现。这覆盖三条战线:树所 import 的 Node builtin、这些 builtin 背后应答的文件系统,以及 bash 工具的进程层——在 `node:child_process` 还是结构桩的时期,工具照常挂载、向模型自我宣告,然后每次调用都失败。 +worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属行——因此浏览器缺失的平台必须在模块层被替换:被代理的模块保持身份、更换实现。这覆盖三条战线:树所 import 的 Node builtin、这些 builtin 背后应答的文件系统,以及 bash 工具的进程层。如果 `node:child_process` 只是结构桩,工具仍会照常挂载并向模型自我宣告,但每次调用都会失败。 ## 决定 **Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。`process` 全局由 worker 自装,装配期填入表中。 -**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。这场排查同时修掉了它周围的静默:cordis 日志器的详细度数值向上计数,未声明等级的 exporter 会丢掉所有 warning——`startWorkerHost` 在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter。 +**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。 -**Shell。** `node:child_process` 是 VFS 之上的真实现。语法是买来的——`@yarnpkg/parsers` 的 `parseShell`——求值器与命令表是自有的,因为每个候选解释器都自带文件系统:管道是逐段传递的字符串,每个程序是 VFS 上的一个函数。命令表就是这台机器的全部 `/bin`;不存在的名字报告 `command not found`(127)。每次 `spawn` 从同一个 bundle 起一个子 Web Worker,首帧声明 shell 进程角色,因此终止梯是真的:`SIGTERM` 在下一命令边界处请求停止,`SIGKILL` 在任意时刻终止 worker——这是线程内解释器永远没有的抢占。文件系统面端到端异步(子进程经帧到宿主 VFS);`execSync`、`execFileSync`、`fork` 拒绝,`node-pty` 保持桩。 +**Shell。** `node:child_process` 是 VFS 之上的真实现。语法是买来的——`@yarnpkg/parsers` 的 `parseShell`——求值器与命令表是自有的,因为每个候选解释器都自带文件系统:管道是逐段传递的字符串,每个程序是 VFS 上的一个函数。普通命令从该表解析;native 包协议可以通过 [watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)提供 Worker 自有的虚拟 executable wrapper。两处都没有的名字在直接 spawn 时报告 `ENOENT`,在 shell source 中则报告 `command not found`(127)。每次 `spawn` 从同一个 bundle 起一个子 Web Worker,首帧声明 shell 进程角色,因此终止梯是真的:`SIGTERM` 在下一命令边界处请求停止,`SIGKILL` 在任意时刻终止 worker——这是线程内解释器永远没有的抢占。文件系统面端到端异步(子进程经帧到宿主 VFS);`execSync`、`execFileSync`、`fork` 拒绝,`node-pty` 保持桩。 ## 曾考虑的替代方案 **整包替换 `dsh-subprocess-local` 或替换 bash 执行器。** 前者让代理表首次替换 workspace 包、违背其自身分类并倒置分层;后者撞上 `dsh-permission-presets` 对 `sandboxMode` 的 boot 期硬校验,并丢掉执行器已被测试钉住的超时/输出行为。 -**`@yarnpkg/shell`、WASM shell、WebContainer。** 配套解释器建立在真实 Node streams 之上(约 1.5 MB 闭包要自养);WASM 已被本部署的决定排除,WASI 没有 `fork`;且它们全都自带文件系统——恰是无法复用的那部分。 +**`@yarnpkg/shell`、WASM shell、WebContainer。** 配套解释器建立在真实 Node streams 之上(约 1.5 MB 闭包要自养);本部署排除 WASM,WASI 没有 `fork`;且它们全都自带文件系统——恰是无法复用的那部分。 **`SharedArrayBuffer` + `Atomics.wait` 给子进程同步文件系统。** 在部署目标实测:无 COOP/COEP 头时 `SharedArrayBuffer` 未定义,而 GitHub Pages 无法设置响应头。异步面是超集;SAB 后端将来可垫入其下而不动任何程序。 @@ -28,7 +28,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属 ## 后果 -- `danger-full-access` 之外的沙箱档 fail loud:`SandboxEnforcement` 没有「未执法」值、浏览器没有内核,`ctx.sandbox.confine` 落闭、命令零启动。在 VFS 帧闸口做真执法是设计中的后续,不属本条。 +- `read-only` 与 `workspace-write` 解释 native Landlock launcher 协议,并在 VFS 帧闸口执行逐进程授权;`danger-full-access` 保持直接进程路径。[Watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)拥有该执行世界中 `full` 的更窄含义。 - Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。 - 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。 - 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml new file mode 100644 index 0000000000..34a24f6261 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.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-23-webworker-vfs-watch-and-landlock.md +2026-08-23-webworker-vfs-watch-and-landlock.md: 61254092e0f32e8e4291fd7c21489684110a1d15 +2026-08-23-webworker-vfs-watch-and-landlock.zh.md: 32e1b36e0ef17e4574252693e246f9d7cd4d4712 diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md new file mode 100644 index 0000000000..61254092e0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md @@ -0,0 +1,80 @@ +# Agent Note: Web Worker VFS watching and CLI-compatible confinement + +Status: implemented + +English | [中文](2026-08-23-webworker-vfs-watch-and-landlock.zh.md) + +## Problem + +The Web Worker preview boots the same Web profile and Agent presets as the Node host. Without a VFS change source, refusing `node:fs.watchFile` makes `skill-filesystem` return an incomplete observation and re-scan on every lookup, while an inert success leaves an existing root waiting forever for Chokidar's `ready`. Settings and credentials likewise need real external-edit events rather than a package-specific fake. + +The same composition mounts `sandbox-local`, whose Linux chain probes bwrap and then `@deepseek-ai/node-addon-landlock-run`. A Worker cannot execute either binary. Ending the selection there makes `workspace-write` and `read-only` unusable even though every shell filesystem operation already crosses a Host-side VFS call point. + +The filesystem compatibility boundary follows the [Worker Node face decision](2026-08-20-webworker-node-face.md): pure JavaScript watcher packages run unchanged over Node-compatible modules. Native or binary packages may keep their public JavaScript API and executable protocol while replacing the backend. An API that cannot preserve its caller-visible Node behavior remains explicitly unavailable; `node:vm` is outside this decision. + +## Decision + +### VFS mutation source and filesystem watchers + +`MemoryVfs` publishes committed `write`, `mkdir`, `remove`, and `chmod` mutations to any number of subscribers. Publication happens after state changes, failed operations publish nothing, image seeding stays silent, and one throwing subscriber cannot fail the filesystem operation or starve another subscriber. Rename is a source removal plus complete destination mkdir/write records; destination writes mark the directory entry as changed, so watchers report `rename` while a future durable sink receives the bytes needed to materialize the destination. Directory mtimes advance when their immediate entry set changes, so polling detects child creation and removal as Node does. + +The mutation record is shared with WebFS persistence rather than defining a second notification path. Writes carry their complete post-commit bytes and virtual permission bits, plus an append offset when only a tail changed. `MemoryVfs` accepts an optional asynchronous `VfsMutationSink`, sends the same records to that sink and live watcher subscribers, and exposes `flush()` through file-handle `sync()` and `datasync()`. Hydration supplies `{ mode, mtimeMs }` explicitly, so image permissions and durable timestamps cannot occupy the same positional argument. This change mounts no durable sink; it keeps the synchronous in-memory tree authoritative so an OPFS or user-directory mirror can hydrate before publication and write behind without changing `node:fs`. + +The `node:fs` implementation provides callback `stat` and `lstat`, `watch`, `watchFile`, `unwatchFile`, `FSWatcher`, and `StatWatcher`; `node:fs/promises.watch` provides the abortable async iterator. One path shares one `StatWatcher` across listeners, listener-specific unwatching leaves peers active, and missing paths report zero-valued Stats before later creation, deletion, and recreation transitions. Callback dispatch captures the registration-time async context and checks closure before every queued delivery. + +`fs.watch` maps entry creation, removal, and rename destinations to `rename`, and maps content or mode changes to `change`. Non-recursive directory watches report immediate child names; recursive watches report paths relative to the watched directory. The VFS has no symlinks, so this implementation does not invent symlink events. + +### Streams and unchanged npm packages + +`node:stream` uses the maintained `readable-stream` browser implementation for `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, pipeline helpers, async iteration, backpressure, aborts, and teardown ordering. The compatibility module sets the byte high-water default to the 64 KiB value used by the repository's Node 22+ engines. VFS-backed `ReadStream` and `WriteStream` supply file descriptors, inclusive ranges, encoding, append or replace behavior, byte accounting, AbortSignal handling, and `open`/`ready`/`finish`/`end`/`close` ordering. + +Chokidar and readdirp are ordinary image dependencies, not module replacements. Their package code runs unchanged and imports the Worker implementations of `node:fs`, `node:fs/promises`, `node:stream`, `node:events`, `node:path`, and `node:os`. Chokidar therefore retains its own initial scan, `ready`, polling, atomic-write normalization, write-settle delay, shared watcher, and close behavior. + +### Landlock CLI over per-process VFS grants + +`@deepseek-ai/node-addon-landlock-run` is an ordinary image dependency, not a module replacement. Its unchanged JavaScript entry runs through the Worker implementations of `node:child_process`, `node:module`, `node:path`, and `node:url`, so the package remains the sole owner of `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT`, `launcherPath()`, `grantArgs()`, and `probe()`. The image may include the matching Linux optional package, but package resolution does not decide whether the Worker platform supplies Landlock: the entry package's deterministic fallback path reaches the same platform executable implementation when that optional package is absent. + +The process layer has a table of Worker platform executables identified by logical executable name rather than one package-manager path. Its `landlock-run` provider accepts a bare command or an absolute launcher path, parses the native package's unchanged CLI, validates every grant root, and delegates the inner argv to the existing shell process runner. `node:child_process` performs only generic executable lookup, output delivery, and settlement. The unchanged package's synchronous `probe()` therefore observes the provider through `spawnSync` and reports `full`. A usage error, missing grant root, or unknown inner executable prints one `landlock-run: ...` line, exits `125`, and never runs the inner command. The bwrap probe remains unavailable, so the unmodified `sandbox-local` Linux chain selects this Landlock backend. + +Each launched process receives its own `ShellFileSystem` guard. `stat`, `list`, and `readText` require a read-only or read-write grant; `writeText`, `mkdir`, and `remove` require a read-write grant; `rename` requires both source and destination to be writable. Denials carry `EACCES` and `permission denied`, preserving `bash-sandbox` denial classification. `/tmp` maps to the VFS `/dsh/tmp`, while `/dev/null` is a virtual empty-read and discarded-write file that stores no bytes. + +The Worker's `full` verdict covers every file operation expressible through its shell command table and Host-served VFS protocol. It does not claim Linux kernel Landlock, arbitrary native executable support, or protection against a future shell program that bypasses `ShellFileSystem`. + +### Explicitly deferred behavior + +`node:vm`, `node:worker_threads`, `node:net`, `node:sqlite`, native PTY, Sharp, and ripgrep remain outside this change. The VFS remains POSIX-only, in-memory, and symlink-free. Browser Workers have no libuv-style ref-counted event loop, so watcher `persistent`, `ref()`, and `unref()` preserve the API and observable state but cannot decide Worker lifetime. + +## Alternatives considered + +**Disable watcher and sandbox rows in the Worker profile.** A smaller composition would stop testing the same Host tree and would hide package integration failures specific to preview deployment. + +**Make `watchFile` an inert success.** Missing roots would never advance, and an existing root would wait forever for Chokidar `ready`. + +**Notify watchers only from `node:fs`.** Shell process requests and any direct VFS writer would bypass the notification point. The commit owner, `MemoryVfs`, is the only complete source. + +**Keep a VFS-specific Chokidar replacement.** This duplicates directory scans, ready accounting, write settling, atomic replacement, shared watcher ownership, and teardown already maintained upstream. + +**Replace the Landlock entry package with a Worker module.** Reimplementing its exported constants, grant builder, launcher resolution, and probe would create a second copy of a package contract that already runs over the Worker Node compatibility layer. Only the platform executable implementation differs. + +**Recognize one exact launcher path.** Optional-dependency installation and the entry package's documented fallback produce different absolute paths for the same executable. Package-manager layout is not the identity of a platform capability, so executable dispatch uses the logical `landlock-run` name. + +**Add a Worker branch to `sandbox-local`.** This would copy policy-to-grant mapping into a business package. Interpreting the existing launcher protocol preserves the provider, consumer, configuration, diagnostics, and native package API. + +**Store one active policy on the global VFS.** Concurrent foreground, background, and escalated commands would overwrite one another's authority. Grants belong to one process handle and its filesystem adapter. + +## Verification + +- `fs-watch-stream.spec.ts` compares missing/create/change/remove `watchFile` transitions and file-stream lifecycle, chunking, range, backpressure, byte count, defaults, and abort identity with the running Node version. +- `chokidar.spec.ts` loads both lockfile-selected Chokidar and readdirp dependency pairs through the Worker transformer and module loader, then proves `ready`, callback watching, polling, missing-file creation, removal, and quiescent close over `MemoryVfs`. +- `image-loadable.spec.ts` packs and loads the real `@deepseek-ai/node-addon-landlock-run` JavaScript, proves it is absent from the replacement table, and runs its fallback `launcherPath()` and `probe()` through the Worker platform executable. `child-process.spec.ts` and `sandbox-stack.spec.ts` then prove the launcher failure code, malformed argv and grant failures, `/tmp` and `/dev/null`, rename denial, all three permission modes, and concurrent process-local grants through the production sandbox and subprocess packages. +- `preview-boot.e2e.ts` builds and boots the packed browser deployment, creates a Workspace and Session, advances missing skill roots into a live Chokidar watch, lists the catalog, and completes settings and credential writes without watcher warnings. + +## Consequences + +The preview now runs npm watcher consumers without source forks, and filesystem mutations observed from Host code or shell process Workers share one ordered commit source. A WebFS/OPFS integration remains an asynchronous mirror around this synchronous authority and consumes that same source; it does not add another Chokidar implementation or a competing mutation protocol. + +Worker `read-only` and `workspace-write` preserve the product's permission vocabulary and denial reporting without forking the Landlock npm package. Their security claim is narrower than native Landlock but complete inside the Worker execution world; any new filesystem message or shell program must continue through the guarded `ShellFileSystem`. Native-backed packages follow the same ownership rule: their JavaScript remains upstream, while the Worker platform replaces only the native artifact behind it. + +The worker bundle gains `readable-stream` and its small browser dependency closure. In return, stream state and backpressure remain maintained upstream instead of becoming local compatibility code. + +Watcher event timing is deterministic from VFS commits rather than inherited from an operating-system backend. This stays within Node's watcher contract, which does not guarantee native event coalescing, while tests pin every event distinction the current consumers require. diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md new file mode 100644 index 0000000000..32e1b36e0e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md @@ -0,0 +1,80 @@ +# Agent Note: Web Worker VFS 监听与 CLI 兼容 confinement + +Status: implemented + +[English](2026-08-23-webworker-vfs-watch-and-landlock.md) | 中文 + +## Problem + +Web Worker preview 启动与 Node host 相同的 Web profile 和 Agent preset。缺少 VFS 变更源时,拒绝 `node:fs.watchFile` 会让 `skill-filesystem` 返回不完整观测并在每次查询时重新扫描,而无事件的成功调用会让已有根永远等待 Chokidar 的 `ready`。Settings 和 credentials 同样需要真实的外部编辑事件,而不是包专用 fake。 + +同一组合挂载 `sandbox-local`,其 Linux 选择链依次探测 bwrap 和 `@deepseek-ai/node-addon-landlock-run`。Worker 无法执行这两个二进制文件。如果选择链到此结束,`workspace-write` 与 `read-only` 将不可用,尽管 shell 的每项文件系统操作已经经过 Host 侧 VFS 调用点。 + +文件系统兼容边界遵循 [Worker Node face 决策](2026-08-20-webworker-node-face.zh.md):纯 JavaScript watcher 包在 Node 兼容模块之上保持原样运行。Native 或 binary 包可以保持公开 JavaScript API 与可执行文件协议,同时替换执行后端。无法维持调用方可见 Node 行为的 API 继续明确标记为不可用;`node:vm` 不属于本决策范围。 + +## Decision + +### VFS mutation source 与文件 watcher + +`MemoryVfs` 向任意数量的订阅方发布已提交的 `write`、`mkdir`、`remove` 和 `chmod` mutation。状态改变后才发布,失败操作不发布,镜像 seed 保持无事件,一个抛错的订阅方也不能让文件系统操作失败或阻止其他订阅方。Rename 被表达为源路径删除与包含完整状态的目标 mkdir/write 记录;目标 write 会标记目录项已改变,因此 watcher 报告 `rename`,未来的 durable sink 同时拿到物化目标所需的字节。目录的直接条目集合改变时,其 mtime 会推进,因此 polling 能像 Node 一样发现子项创建和删除。 + +Mutation record 与 WebFS 持久化共用,而不建立第二条通知路径。Write 记录携带提交后的完整字节与虚拟权限位,并在只有尾部变化时携带 append offset。`MemoryVfs` 接受可选的异步 `VfsMutationSink`,把同一批记录交给 sink 与实时 watcher 订阅方,并通过文件句柄的 `sync()` 和 `datasync()` 暴露 `flush()`。水合通过显式的 `{ mode, mtimeMs }` 传入元数据,因此镜像权限与持久化时间戳不会占用同一个位置参数。本次变更不挂载 durable sink;同步内存树继续作为权威,因此 OPFS 或用户目录 mirror 可以先水合、再异步写回,而无需改变 `node:fs`。 + +`node:fs` 实现 callback `stat` 和 `lstat`、`watch`、`watchFile`、`unwatchFile`、`FSWatcher` 与 `StatWatcher`;`node:fs/promises.watch` 提供可由 abort 取消的异步迭代器。同一路径的 listener 共享一个 `StatWatcher`,按 listener 取消监听不会影响其他 listener;缺失路径先报告零值 Stats,随后再报告创建、删除和重建状态。Callback 分发捕获注册时的异步上下文,并在每次排队交付前检查 watcher 是否已经关闭。 + +`fs.watch` 把条目创建、删除和 rename 目标映射为 `rename`,把内容或 mode 变化映射为 `change`。非递归目录 watcher 报告直接子项名,递归 watcher 报告相对被监听目录的路径。VFS 没有符号链接,因此该实现不会制造符号链接事件。 + +### Stream 与未修改的 NPM 包 + +`node:stream` 使用维护中的 `readable-stream` 浏览器实现来提供 `Readable`、`Writable`、`Duplex`、`Transform`、`PassThrough`、pipeline helper、异步迭代、backpressure、abort 和 teardown 顺序。兼容模块把字节流 high-water mark 默认值设为仓库 Node 22+ 引擎使用的 64 KiB。VFS 支持的 `ReadStream` 与 `WriteStream` 提供文件描述符、闭区间范围、encoding、追加或替换行为、字节计数、AbortSignal 处理,以及 `open`、`ready`、`finish`、`end`、`close` 顺序。 + +Chokidar 和 readdirp 作为普通镜像依赖运行,不属于模块 replacement。它们的包代码保持原样,并导入 Worker 实现的 `node:fs`、`node:fs/promises`、`node:stream`、`node:events`、`node:path` 与 `node:os`。因此,初次扫描、`ready`、polling、原子写归一化、写入稳定等待、共享 watcher 与关闭行为仍由 Chokidar 自己负责。 + +### 基于逐进程 VFS 授权的 Landlock CLI + +`@deepseek-ai/node-addon-landlock-run` 是普通镜像依赖,不是模块 replacement。其未经修改的 JavaScript 入口通过 Worker 实现的 `node:child_process`、`node:module`、`node:path` 与 `node:url` 运行,因此该包仍是 `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`、`launcherPath()`、`grantArgs()` 和 `probe()` 的唯一所有者。镜像可以包含匹配的 Linux optional package,但包解析不决定 Worker 平台是否提供 Landlock;缺少该 optional package 时,入口包产生的确定性 fallback 路径仍到达同一个平台可执行文件实现。 + +进程层持有按逻辑可执行文件名识别的 Worker 平台可执行文件表,而不依赖某一个包管理器路径。其 `landlock-run` provider 接受裸命令或绝对 launcher 路径,解析 native 包未经修改的 CLI、校验每个授权根,并把内部 argv 交给既有 shell 进程 runner。`node:child_process` 只负责通用的可执行文件查找、输出投递与结束处理。因此,原包的同步 `probe()` 会通过 `spawnSync` 观察到该 provider 并报告 `full`。用法错误、缺失的授权根或未知内部可执行文件只输出一行 `landlock-run: ...`,以 `125` 退出,并且绝不运行内部命令。bwrap 仍探测为不可用,因此未修改的 `sandbox-local` Linux 选择链会选中该 Landlock 后端。 + +每个已启动进程分别获得一个 `ShellFileSystem` guard。`stat`、`list` 和 `readText` 需要只读或读写授权;`writeText`、`mkdir` 和 `remove` 需要读写授权;`rename` 要求源和目标都可写。拒绝错误包含 `EACCES` 与 `permission denied`,从而保持 `bash-sandbox` 的拒绝分类。`/tmp` 映射到 VFS 的 `/dsh/tmp`,`/dev/null` 则是空读、丢弃写入且不保存任何字节的虚拟文件。 + +Worker 的 `full` 结论覆盖 shell 命令表和 Host 服务 VFS 协议能够表达的全部文件操作。它不表示 Linux 内核 Landlock、不支持任意 native 可执行文件,也无法约束未来绕过 `ShellFileSystem` 的 shell 程序。 + +### 明确延后的行为 + +`node:vm`、`node:worker_threads`、`node:net`、`node:sqlite`、native PTY、Sharp 和 ripgrep 不属于本次变更。VFS 仍然只支持 POSIX、内存存储且没有符号链接。Browser Worker 没有 libuv 风格的引用计数事件循环,因此 watcher 的 `persistent`、`ref()` 和 `unref()` 保留 API 与可观察状态,但不能决定 Worker 生存期。 + +## Alternatives considered + +**在 Worker profile 中禁用 watcher 与 sandbox 配置项。** 缩减组合后将不再测试相同的 Host tree,还会隐藏 preview 部署特有的包集成故障。 + +**让 `watchFile` 成为无事件的成功调用。** 缺失根永远无法推进,已有根则会永久等待 Chokidar `ready`。 + +**只从 `node:fs` 通知 watcher。** Shell 进程请求以及直接写 VFS 的实现可以绕过通知点。只有提交状态的 `MemoryVfs` 才是完整真源。 + +**保留 VFS 专用的 Chokidar replacement。** 这会重复实现上游已经维护的目录扫描、ready 计数、写入稳定等待、原子替换、共享 watcher 所有权和 teardown。 + +**用 Worker 模块替换 Landlock 入口包。** 重新实现其导出常量、授权参数构造、launcher 解析和 probe,会为一个已经能在 Worker Node 兼容层上运行的包约定建立第二份副本。只有平台可执行文件实现需要不同。 + +**只识别一个精确 launcher 路径。** Optional dependency 的安装状态与入口包已有的 fallback 会为同一个可执行文件产生不同的绝对路径。包管理器布局不是平台能力的身份,因此可执行文件分发使用逻辑名称 `landlock-run`。 + +**在 `sandbox-local` 中增加 Worker 分支。** 这会把策略到授权的映射复制到业务包中。解释现有 launcher 协议可以保持 provider、consumer、配置、诊断和 native 包 API 不变。 + +**在全局 VFS 上保存一个当前策略。** 并发前台、后台和升权命令会覆盖彼此的权限。授权必须归属于单个进程句柄及其文件系统适配器。 + +## Verification + +- `fs-watch-stream.spec.ts` 对照当前 Node 版本验证缺失、创建、修改、删除的 `watchFile` 状态转换,以及文件流生命周期、分片、范围、backpressure、字节计数、默认值和 abort 身份。 +- `chokidar.spec.ts` 通过 Worker transformer 与模块 loader 加载 lockfile 选定的两组 Chokidar 和 readdirp 依赖,并在 `MemoryVfs` 上验证 `ready`、callback watcher、polling、缺失文件创建、删除和完全停稳的关闭。 +- `image-loadable.spec.ts` 打包并加载真实的 `@deepseek-ai/node-addon-landlock-run` JavaScript,验证它不在 replacement 表中,并让其 fallback `launcherPath()` 与 `probe()` 经过 Worker 平台可执行文件。`child-process.spec.ts` 与 `sandbox-stack.spec.ts` 随后通过生产 sandbox 和 subprocess 包验证 launcher 失败码、错误 argv 与授权失败、`/tmp` 与 `/dev/null`、rename 拒绝、三种权限模式和逐进程并发授权。 +- `preview-boot.e2e.ts` 构建并启动打包后的浏览器部署,创建 Workspace 与 Session,把缺失的 skill 根逐级推进到可用的 Chokidar watch,读取 catalog,并在没有 watcher 警告的情况下完成 settings 与 credential 写入。 + +## Consequences + +Preview 现在可以在不 fork 源码的情况下运行 NPM watcher 消费方;Host 代码与 shell 进程 Worker 产生的文件系统 mutation 共享同一个有序提交源。WebFS/OPFS 集成仍是围绕该同步权威的异步 mirror,并消费同一个变更源;它不会增加另一份 Chokidar 实现或互相竞争的 mutation 协议。 + +Worker `read-only` 与 `workspace-write` 在不 fork Landlock NPM 包的情况下保留产品权限词汇和拒绝报告。其安全结论比 native Landlock 更窄,但完整覆盖 Worker 执行世界;任何新的文件系统消息或 shell 程序都必须继续经过受 guard 保护的 `ShellFileSystem`。Native-backed 包遵循同一所有权规则:其 JavaScript 保持上游实现,Worker 平台只替换背后的 native artifact。 + +Worker bundle 增加 `readable-stream` 及其少量浏览器依赖。相应地,stream 状态和 backpressure 继续由上游维护,不成为本地兼容代码。 + +Watcher 事件时序由 VFS 提交确定,而不是继承操作系统后端。Node watcher 约定本身不保证 native 事件合并方式,因此该实现仍符合约定;测试固定当前消费方依赖的每一种事件区别。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b82665e1c9..f2637fe699 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -87,6 +87,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | +| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | @@ -140,6 +141,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index b2d6add83a..1b8bc85f32 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -8,7 +8,9 @@ * Two milestones prove that happened — the host's `tree active` boot line, * whose lowering contract must be the one this checkout's packer emits, and the * workspace hero, which paints only after the client tree comes up over the - * tunnel. + * tunnel. The same page then creates a Workspace and Session, lists skills, + * and writes through the settings and credentials providers, exercising the + * upstream Chokidar instances over the Worker filesystem implementation. * * The site is served the way a static host serves it: bytes from `dist/` with * no rewrite rules, so a missing file is a 404 rather than the index page. @@ -212,6 +214,7 @@ it('boots the packed worker deployment to an interactive page', async () => { async function bootPreview(origin: string, browser: Browser): Promise { const page = await newEnglishPage(browser) const pageErrors: Error[] = [] + const consoleErrors: string[] = [] page.on('pageerror', (error) => { pageErrors.push(error) }) // Registered before navigation: the worker reports its tree long before the // tunnel serves the client, so a listener added later would miss the line. @@ -219,6 +222,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { page.on('console', (message) => { const text = message.text() if (text.includes(TREE_ACTIVE)) reported(text) + if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(text) }) }) try { @@ -232,7 +236,92 @@ async function bootPreview(origin: string, browser: Browser): Promise { // surface, so it appears only once the startup chain completed over the // tunnel. await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + const continueButton = page.getByRole('button', { name: 'Continue' }) + if (await continueButton.isVisible()) await continueButton.click() + await page.getByRole('button', { name: 'Configure later' }).click() + await page.getByRole('textbox', { name: 'Choose workspace' }).click() + const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Edit path' }).click() + const pathInput = dialog.getByRole('textbox', { name: 'Edit path' }) + await pathInput.fill('/dsh/workspace') + await pathInput.press('Enter') + await dialog.getByRole('button', { name: 'Open', exact: true }).click() + await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') + .waitFor({ timeout: 30_000 }) + + const exercised = await page.evaluate(async () => { + type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } + interface PreviewApi { + host: { createDirectory(payload: { path: string; name: string }): Promise> } + skills: { list(payload: { sessionId: string }): Promise> } + settings: { + describe(payload: object): Promise }>> + update(payload: { ns: string; patch: object; expectedRevision: number }): Promise> + } + credentials: { + set(payload: { ref: string; value: string }): Promise> + unset(payload: { ref: string }): Promise> + describe(payload: { refs: string[] }): Promise + }>> + } + } + interface PreviewTransport { + fetch(input: string, init: RequestInit): Promise + createApiClient(): PreviewApi + } + const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: PreviewTransport }).__DSH_TRANSPORT__ + if (transport === undefined) throw new Error('preview transport is absent after boot') + const response = await transport.fetch('/api/session/list', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'preview-session-list', method: 'session/list', + payload: { args: { _request: {} } }, + }), + }) + const sessions = await response.json() as Result<{ items: Array<{ sessionId: string }> }> + if (!sessions.result.ok) throw new Error(`session/list failed: ${sessions.result.error.message}`) + const sessionId = sessions.result.value.items[0]?.sessionId + if (sessionId === undefined) throw new Error('workspace adoption created no Session') + + const api = transport.createApiClient() + const skills = await api.skills.list({ sessionId }) + if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`) + const createDirectory = async (path: string, name: string): Promise => { + const created = await api.host.createDirectory({ path, name }) + if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`) + await new Promise((resolve) => { setTimeout(resolve, 250) }) + const refreshed = await api.skills.list({ sessionId }) + if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) + } + await createDirectory('/dsh/workspace', '.agents') + await createDirectory('/dsh/workspace/.agents', 'skills') + await createDirectory('/dsh/workspace/.agents/skills', 'placeholder') + const settings = await api.settings.describe({}) + if (!settings.result.ok) throw new Error(`settings.describe failed: ${settings.result.error.message}`) + const shell = settings.result.value.namespaces.find(namespace => namespace.ns === 'shell') + if (shell === undefined) throw new Error('settings.describe omitted the shell namespace') + const updated = await api.settings.update({ ns: 'shell', patch: { timeoutMs: 61_000 }, expectedRevision: shell.revision }) + if (!updated.result.ok) throw new Error(`settings.update failed: ${updated.result.error.message}`) + const stored = await api.credentials.set({ ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' }) + if (!stored.result.ok) throw new Error(`credentials.set failed: ${stored.result.error.message}`) + const credentials = await api.credentials.describe({ refs: ['PREVIEW_TEST_SECRET'] }) + if (!credentials.result.ok) throw new Error(`credentials.describe failed: ${credentials.result.error.message}`) + const removed = await api.credentials.unset({ ref: 'PREVIEW_TEST_SECRET' }) + if (!removed.result.ok) throw new Error(`credentials.unset failed: ${removed.result.error.message}`) + await new Promise((resolve) => { setTimeout(resolve, 250) }) + return { + skillCount: skills.result.value.skills.length, + credentialConfigured: credentials.result.value.credentials.PREVIEW_TEST_SECRET?.configured, + } + }) + expect(exercised.skillCount).toBeGreaterThanOrEqual(0) + expect(exercised.credentialConfigured).toBe(true) expect(pageErrors.map(error => error.message)).toEqual([]) + expect(consoleErrors.filter(line => + /watchFile|failed to watch|node-addon-landlock-run\.probe|sandbox backend is usable|SANDBOX_UNAVAILABLE/i.test(line))).toEqual([]) } catch (error) { await saveFailureShot(page, 'preview-boot') throw pageErrors.length === 0 diff --git a/packages/experimental/webworker-packer/README.i18n.yaml b/packages/experimental/webworker-packer/README.i18n.yaml index cab7fecde4..040b58adda 100644 --- a/packages/experimental/webworker-packer/README.i18n.yaml +++ b/packages/experimental/webworker-packer/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/experimental/webworker-packer/README.md -README.md: eb6c4c60106ebb7f6bb123116a749f152dea79a0 -README.zh.md: defa06c048125d627002c445aca491b4a33c4026 +README.md: a04f7579c8b3ddc7e94d2f8ed21251ed3efae302 +README.zh.md: 7876212ffb6ded5c45659502306758d8dd316f67 diff --git a/packages/experimental/webworker-packer/README.md b/packages/experimental/webworker-packer/README.md index eb6c4c6010..a04f7579c8 100644 --- a/packages/experimental/webworker-packer/README.md +++ b/packages/experimental/webworker-packer/README.md @@ -10,7 +10,7 @@ The pack is a three-layer standard stack: 2. **Publish view** — each workspace package contributes the slice npm would publish (`files` through picomatch) minus the rule tables in `src/rules.ts` (no sources, no workspace `dist/`; external packages keep their trees minus the same exclude globs). 3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time. -`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The CLI is `dsh-pack-vfs-image --out [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build. +`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/landlock-run/packages/`, and `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build. ## Model Experience diff --git a/packages/experimental/webworker-packer/README.zh.md b/packages/experimental/webworker-packer/README.zh.md index defa06c048..7876212ffb 100644 --- a/packages/experimental/webworker-packer/README.zh.md +++ b/packages/experimental/webworker-packer/README.zh.md @@ -10,7 +10,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后 2. **发布视图**——每个 workspace 包贡献 npm 会发布的切片(`files` 走 picomatch),再减去 `src/rules.ts` 的规则表(无源码、无 workspace `dist/`;外部包保留整棵减同一套 exclude glob)。 3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。 -`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。CLI 为 `dsh-pack-vfs-image --out [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。 +`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/landlock-run/packages/` 与 `apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。 ## 模型体验 diff --git a/packages/experimental/webworker-packer/src/repository.ts b/packages/experimental/webworker-packer/src/repository.ts index 38ec64bd1d..6ae2e59bee 100644 --- a/packages/experimental/webworker-packer/src/repository.ts +++ b/packages/experimental/webworker-packer/src/repository.ts @@ -16,11 +16,11 @@ import type { ConfigTree, PackResult } from './pack.ts' /** * Repository directories scanned for workspace and vendored packages. The - * image only ever materializes runtime packages, which all live here; - * examples, python, and native are never on a roster's dependency chain (the - * native addon is a replaced external). + * image only ever materializes runtime packages, which live here. The Landlock + * package family contributes its unchanged JavaScript entry from `native/`; + * examples and python never occur on a roster's dependency chain. */ -const WORKSPACE_SCAN_ROOTS = ['vendor', 'packages', 'apps'] +const WORKSPACE_SCAN_ROOTS = ['vendor', 'packages', 'native/landlock-run/packages', 'apps'] /** Composition entry point package: the `dsh` CLI, run from source. */ const CLI_PACKAGE = 'apps/cli' diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index 688b21c279..e75b4f523b 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -21,7 +21,9 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts' -import { WorkerModuleLoader } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' +import { + setActiveModuleLoader, WorkerModuleLoader, +} from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts' import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' import { indexWorkspacePackages } from '../src/repository.ts' @@ -31,6 +33,7 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) /** A leaf workspace package: real build output, no dependencies to drag in. */ const SUBJECT = '@deepseek-ai/dsh-timeout' +const LANDLOCK = '@deepseek-ai/node-addon-landlock-run' const workspaces = indexWorkspacePackages(repoRoot) @@ -53,6 +56,15 @@ const packed = (): ReturnType => memo ??= packVfsImage({ entries: [], }) +let landlockMemo: ReturnType | undefined +const packedLandlock = (): ReturnType => landlockMemo ??= packVfsImage({ + config: `- id: subject\n name: '${LANDLOCK}'\n`, + profile: 'landlock-package-check', + workspaces, + resolveFrom: repoRoot, + entries: [], +}) + /** The image's archive, inflated once: mounting reads the tar, not the gzip member. */ let archiveMemo: Uint8Array | undefined const archive = async (): Promise => @@ -138,6 +150,41 @@ const archive = async (): Promise => expect(loader.usage().modules).toBeGreaterThan(0) }) + it('runs the unchanged Landlock entry package over the Worker platform executable', async () => { + const result = packedLandlock() + expect(workspaces.has(LANDLOCK)).toBe(true) + expect(result.packages.has(LANDLOCK)).toBe(true) + expect(result.missing).toEqual([]) + expect(Object.hasOwn(result.files, `node_modules/${LANDLOCK}/lib/index.js`)).toBe(true) + expect(createNodeBuiltins()[LANDLOCK]).toBeUndefined() + + const vfs = loadVfsImage(await inflateImage(result.image, 'the packed Landlock package'), DEFAULT_ROOT) + const loader = new WorkerModuleLoader({ + vfs, + root: DEFAULT_ROOT, + staticModules: createNodeBuiltins(), + staticModulePrefixes: REPLACED_PREFIXES, + }) + setActiveModuleLoader(loader) + const landlock = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(LANDLOCK) as { + LAUNCHER_BIN: string + LAUNCHER_FAILURE_EXIT: number + launcherPath(): string + grantArgs(grants: { readOnly?: readonly string[]; readWrite?: readonly string[] }): string[] + probe(): string + } + + expect(landlock.LAUNCHER_BIN).toBe('landlock-run') + expect(landlock.LAUNCHER_FAILURE_EXIT).toBe(125) + expect(landlock.grantArgs({ readOnly: ['/'], readWrite: ['/tmp'] })).toEqual([ + '--ro', '/', '--rw', '/tmp', + ]) + expect(landlock.launcherPath()).toBe( + `${DEFAULT_ROOT}/node_modules/${LANDLOCK}/node_modules/${LANDLOCK}-${process.platform}-${process.arch}/bin/landlock-run`, + ) + expect(landlock.probe()).toBe('full') + }) + it('refuses a body the packer did not lower, naming the image', async () => { // The case above only proves the packed bytes are wrappable. This is the // other half: the loader has no transform to fall back on, so an entry the diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 0dced963dc..6dc2eb15e7 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: b82c65b981be6a9405ae72e3a24a42c68b52696e -README.zh.md: 97160641a38095026d5103f2e423846bfb41d5a6 +README.md: 88d4213b5eb2f82cc41ad5059d9473d4a8abf53b +README.zh.md: 6f4fd2612d5daa28831890ff64171ad780274958 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index b82c65b981..88d4213b5e 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -7,8 +7,8 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the image (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and replaced externals. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). -- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). The grammar is `@yarnpkg/parsers`' `parseShell`; this package owns the evaluator (pipelines, `&&`/`||`, subshells, redirections, expansion, globs) and the command table, which is the only `/bin` that exists — a name it does not hold reports `command not found`, and `execSync`/`fork` still refuse, because they need a real process. +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). +- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — `connectWorkerHost(worker, { image? })` completes the pre-Cordis handshake: the opening `init` frame carries the image URL (the one deployment-shaped input), the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the worker boot in headless Chromium. @@ -24,9 +24,9 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`. -- **The skill catalog is never cached in the worker** — `skill-filesystem` watches its roots through `node:fs.watchFile`, which this package refuses, so every discovery pass returns an incomplete observation and re-scans. Discovery itself stays correct; the cost is a re-scan on every pass. - **`node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing a real process or realm isolation cannot run here. -- **The bash tool runs only under `danger-full-access`**: a browser has no kernel to confine a command with, so `ctx.sandbox.confine` fails loud in every other permission preset and the command never starts. The mode is the deployment's own user-facing switch, not a worker-specific composition. +- **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop. +- **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation. - **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there. - **The shell is not bash**: no loops, functions, `case`, job control, or process substitution — the grammar stops at pipelines, `&&`/`||`, subshells, groups, redirections, and expansion. `&` runs its command to completion in place, `sed` accepts only substitution scripts, patterns are JavaScript regular expressions, and the command table holds coreutils only (no `git`, no network tools). - **A shell process has no synchronous filesystem**: it reads and writes the host's VFS by message, because blocking on a reply would need `SharedArrayBuffer`, which requires a cross-origin isolation GitHub Pages cannot grant. Directory-walking commands therefore cost one round trip per entry, and two concurrent commands can interleave their writes. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 97160641a3..6f4fd2612d 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -7,8 +7,8 @@ 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载镜像(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS/隧道/浏览器原语,浏览器做不到的走结构化 stub(调用即 console 报错并抛出),外部包整体替换。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 -- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。语法来自 `@yarnpkg/parsers` 的 `parseShell`;求值器(管道、`&&`/`||`、子 shell、重定向、展开、glob)与命令表由本包自持,而命令表就是这里唯一存在的 `/bin`——表里没有的名字报 `command not found`,`execSync`/`fork` 依然拒绝,因为它们需要真进程。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 +- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——`connectWorkerHost(worker, { image? })` 完成 pre-Cordis 握手:开局 `init` 帧携带镜像 URL(唯一部署形态输入),boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 worker 启动。 @@ -24,9 +24,9 @@ ## Known Limitations and Deferred Work - **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`。 -- **worker 里的技能目录从不缓存**——`skill-filesystem` 用 `node:fs.watchFile` 监听各个根,而本包拒绝该调用,于是每轮发现都返回不完整观测并重新扫描。发现本身仍然正确,代价是每轮都要重扫。 - **`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要真进程或真 realm 隔离的行在此无法运行。 -- **bash 工具只在 `danger-full-access` 下可用**:浏览器没有内核可以约束命令,因此在其余权限档位下 `ctx.sandbox.confine` 会响亮失败、命令根本不会启动。该档位是部署本身的用户面开关,不是 worker 特有的组合差异。 +- **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent`、`ref()` 和 `unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。 +- **Worker confinement 是 VFS 边界,不是内核 Landlock**:`read-only` 和 `workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。 - **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。 - **这个 shell 不是 bash**:没有循环、函数、`case`、作业控制或进程替换——语法止步于管道、`&&`/`||`、子 shell、group、重定向与展开。`&` 会就地把命令跑完,`sed` 只接受替换脚本,模式是 JavaScript 正则,命令表只有 coreutils(没有 `git`,没有网络工具)。 - **shell 进程没有同步文件面**:它靠消息读写宿主的 VFS,因为阻塞等待回帧需要 `SharedArrayBuffer`,而那要求 GitHub Pages 给不了的跨源隔离。因此目录遍历类命令每个条目一次往返,并发的两条命令写入可以交错。 diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index c083f13fc1..422bd4f5ec 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -35,7 +35,8 @@ "@yarnpkg/parsers": "^3.1.0", "acorn": "^8.17.0", "buffer": "^6.0.3", - "picomatch": "^4.0.4" + "picomatch": "^4.0.4", + "readable-stream": "^4.7.0" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -49,12 +50,18 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@types/picomatch": "^3.0.2" + "@deepseek-ai/node-addon-landlock-run": "workspace:^", + "@types/picomatch": "^3.0.2", + "@types/readable-stream": "^4.0.24", + "chokidar": "^5.0.0" }, "files": [ "lib/index.js", diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index e30128d293..4e95da027c 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -56,7 +56,7 @@ export const MODULE_PROXIES: Record = { 'node:child_process': './node/builtin_modules/implemented/child_process.ts', // Structural mocks: every symbol exists, every call throws. 'node:net': './node/builtin_modules/mock/net.ts', - 'node:stream': './node/builtin_modules/mock/stream.ts', + 'node:stream': './node/builtin_modules/implemented/stream.ts', 'node:vm': './node/builtin_modules/mock/vm.ts', 'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts', 'node:sqlite': './node/builtin_modules/mock/sqlite.ts', @@ -66,10 +66,8 @@ export const MODULE_PROXIES: Record = { 'node-pty': './node/external_packages/node-pty.ts', '@vscode/ripgrep': './node/external_packages/ripgrep.ts', '@earendil-works/pi-ai': './node/external_packages/pi-ai.ts', - '@deepseek-ai/node-addon-landlock-run': './node/external_packages/node-addon-landlock-run.ts', // Constructible fakes whose methods are never reached. 'ws': './node/external_packages/ws.ts', - 'chokidar': './node/external_packages/chokidar.ts', } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts index 2c863ce6bb..e70d1323d1 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts @@ -5,9 +5,10 @@ * `spawn` starts the argv as a shell process (`src/shell/process/`) — its own * Web Worker, off this thread — and reports it through the `ChildProcess` * surface the subprocess service consumes: pipes, `exit`/`close`, pid, and - * signals, with `SIGKILL` terminating the worker for real. The command table - * is the only `/bin` that exists, so a name it does not hold fails with - * `ENOENT`, exactly as a missing binary does on a real host. + * signals, with `SIGKILL` terminating the worker for real. Worker-owned + * executable wrappers resolve before the shell's command table; anything in + * neither set fails with `ENOENT`, exactly as a missing binary does on a real + * host. * * What stays impossible is what needs a real process: synchronous execution * (`execSync`, and `spawnSync` for a known program) and `fork`. @@ -19,7 +20,11 @@ import { EventEmitter } from './events.ts' import { notImplementedFail } from '../../notImplementedFail.ts' import { registerProcess, releaseProcess, signalProcess } from '../../process-table.ts' import { startProcess } from '../../../shell/process/host.ts' +import { hostFileSystem } from '../../../shell/fs-access.ts' +import { virtualExecutable } from '../../../shell/process/virtual-executables.ts' +import type { VirtualExecutableExit } from '../../../shell/process/virtual-executables.ts' import { standardPrograms } from '../../../shell/programs/index.ts' +import type { ShellFileSystem } from '../../../shell/types.ts' import { DSH_ROOT } from '../../../storage/paths.ts' const MODULE = 'node:child_process' @@ -219,9 +224,6 @@ export function spawn( const entry = registerProcess() const child = new WorkerChildProcess(entry.pid, stdio) - const script = shellScriptOf(argv) - const known = script !== undefined || standardPrograms().has(program) - const emit = (stream: 'stdout' | 'stderr', text: string): void => { if (text === '') return const pipe = stream === 'stdout' ? child.stdout : child.stderr @@ -236,7 +238,10 @@ export function spawn( } } + let settled = false const settle = (exitCode: number): void => { + if (settled) return + settled = true releaseProcess(entry.pid) // A signalled command reports no exit code, which is what makes the // subprocess service classify it as killed rather than finished. @@ -248,31 +253,68 @@ export function spawn( child.emit('exit', child.exitCode, signal) child.emit('close', child.exitCode, signal) } + const failSpawn = (error: Error): void => { + if (settled) return + settled = true + releaseProcess(entry.pid) + child.emit('error', error) + } // The command starts on a microtask, so a caller that attaches listeners and // writes standard input right after `spawn()` — the subprocess service does // exactly that — is never racing the first output. queueMicrotask(() => { - if (!known) { - releaseProcess(entry.pid) - child.emit('error', spawnEnoent(program)) - return - } - entry.process = startProcess({ - script, - argv, - cwd: options.cwd ?? DSH_ROOT, - env: environmentOf(options.env), - stdin: child.stdin?.contents() ?? '', - onOutput: emit, - onExit: settle, + void (async () => { + const cwd = options.cwd ?? DSH_ROOT + let commandArgv: readonly string[] = argv + let filesystem: ShellFileSystem | undefined + let missingExecutable: VirtualExecutableExit | undefined + const executable = virtualExecutable(program) + if (executable !== undefined) { + const prepared = await executable.prepare(args, { cwd, filesystem: hostFileSystem() }) + if (prepared.kind === 'exit') { + emit('stdout', prepared.stdout) + emit('stderr', prepared.stderr) + settle(prepared.exitCode) + return + } + commandArgv = prepared.argv + filesystem = prepared.filesystem + missingExecutable = prepared.missingExecutable + } + + const command = commandArgv[0] as string + const script = shellScriptOf(commandArgv) + const known = script !== undefined || standardPrograms().has(command) + if (!known) { + if (missingExecutable !== undefined) { + emit('stdout', missingExecutable.stdout) + emit('stderr', missingExecutable.stderr) + settle(missingExecutable.exitCode) + } else { + failSpawn(spawnEnoent(program)) + } + return + } + entry.process = startProcess({ + script, + argv: commandArgv, + cwd, + env: environmentOf(options.env), + stdin: child.stdin?.contents() ?? '', + onOutput: emit, + onExit: settle, + ...filesystem === undefined ? {} : { fs: filesystem }, + }) + // A signal that arrived while the process was still starting has to reach + // it now; the table recorded it but had nothing to deliver it to. + if (entry.signal !== undefined) { + if (entry.signal === 'SIGKILL') entry.process.destroy() + else entry.process.interrupt() + } + })().catch((error: unknown) => { + failSpawn(error instanceof Error ? error : new Error(String(error))) }) - // A signal that arrived while the process was still starting has to reach - // it now; the table recorded it but had nothing to deliver it to. - if (entry.signal !== undefined) { - if (entry.signal === 'SIGKILL') entry.process.destroy() - else entry.process.interrupt() - } }) return child @@ -298,10 +340,22 @@ export interface WorkerSpawnSyncResult { * answers in the same shape: absent programs report `ENOENT`, and a program * this shell *does* have reports that only the asynchronous path can run it. * @param program - the program name. + * @param args - arguments passed to the virtual launcher probe. * @returns the Node-shaped synchronous result carrying the failure. */ -export function spawnSync(program: string): WorkerSpawnSyncResult { +export function spawnSync(program: string, args: readonly string[] = []): WorkerSpawnSyncResult { const empty = Buffer.alloc(0) + const executable = virtualExecutable(program) + if (executable !== undefined) { + const result = executable.runSync(args) + if (result.kind === 'asynchronous') { + const error = new Error(`${MODULE}.spawnSync cannot run ${program} in the worker host: commands run asynchronously`) + return { pid: -1, status: null, signal: null, stdout: empty, stderr: empty, output: [null, empty, empty], error } + } + const stdout = Buffer.from(result.stdout) + const stderr = Buffer.from(result.stderr) + return { pid: -1, status: result.exitCode, signal: null, stdout, stderr, output: [null, stdout, stderr] } + } const error = standardPrograms().has(program) ? new Error(`${MODULE}.spawnSync cannot run ${program} in the worker host: commands run asynchronously`) : spawnEnoent(program) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts new file mode 100644 index 0000000000..396ae3c22e --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts @@ -0,0 +1,419 @@ +/** Node filesystem watching over the active in-memory VFS. */ +import { Buffer } from 'buffer' +import { EventEmitter } from './events.ts' +import { captureAsyncContext, runWithAsyncContext } from './async_hooks.ts' +import { basename, relative, resolve, sep } from './path.ts' +import { requireActiveVfs } from '../../../storage/active.ts' +import type { VfsBigIntStats, VfsMutation, VfsStats } from '../../../storage/types.ts' + +type PathArg = string | URL | Uint8Array +type WatchListener = (eventType: 'rename' | 'change', filename: string | Buffer | null) => void +type WatchStats = VfsStats | VfsBigIntStats +type StatListener = (current: WatchStats, previous: WatchStats) => void + +/** Options shared by the callback and promise watch faces. */ +export interface WatchOptions { + persistent?: boolean + recursive?: boolean + encoding?: BufferEncoding | 'buffer' + signal?: AbortSignal +} +/** Poll-style watch options. */ +export interface WatchFileOptions { + persistent?: boolean + interval?: number + bigint?: boolean +} + +const asPath = (path: PathArg): string => { + if (typeof path === 'string') return resolve(path) + if (path instanceof URL) return resolve(decodeURIComponent(path.pathname)) + return resolve(new TextDecoder().decode(path)) +} + +const missingStats = (bigint: boolean): WatchStats => ({ + size: bigint ? 0n : 0, + ino: bigint ? 0n : 0, + mtimeMs: bigint ? 0n : 0, + ctimeMs: bigint ? 0n : 0, + atimeMs: bigint ? 0n : 0, + birthtimeMs: bigint ? 0n : 0, + mtime: new Date(0), + mode: bigint ? 0n : 0, + ...bigint ? { + dev: 0n, + nlink: 0n, + mtimeNs: 0n, + ctimeNs: 0n, + atimeNs: 0n, + birthtimeNs: 0n, + ctime: new Date(0), + atime: new Date(0), + birthtime: new Date(0), + } : {}, + isFile: () => false, + isDirectory: () => false, + isSymbolicLink: () => false, + isFIFO: () => false, + isSocket: () => false, + isBlockDevice: () => false, + isCharacterDevice: () => false, +}) as WatchStats + +const statOrMissing = (path: string, bigint: boolean): WatchStats => { + try { + return requireActiveVfs().statSync(path, { bigint }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return missingStats(bigint) + throw error + } +} + +const statsChanged = (left: WatchStats, right: WatchStats): boolean => + left.size !== right.size + || left.mtimeMs !== right.mtimeMs + || left.mode !== right.mode + || left.ino !== right.ino + || left.isFile() !== right.isFile() + || left.isDirectory() !== right.isDirectory() + +const contains = (parent: string, child: string): boolean => + parent === '/' || child === parent || child.startsWith(`${parent}${sep}`) + +const overlaps = (left: string, right: string): boolean => contains(left, right) || contains(right, left) + +const abortError = (reason?: unknown): Error & { code: string; cause?: unknown } => { + const error = new Error('The operation was aborted') as Error & { code: string } + error.name = 'AbortError' + error.code = 'ABORT_ERR' + if (reason !== undefined) error.cause = reason + return error +} + +/** `fs.FSWatcher` over VFS mutations. */ +export class FSWatcher extends EventEmitter { + private readonly disposeMutation: () => void + private readonly signal: AbortSignal | undefined + private readonly onAbort: (() => void) | undefined + private closed = false + private referenced: boolean + + constructor( + private readonly target: string, + private readonly directory: boolean, + private readonly options: WatchOptions, + listener?: WatchListener, + ) { + super() + this.referenced = options.persistent ?? true + const context = captureAsyncContext() + if (listener !== undefined) this.on('change', listener as (...args: unknown[]) => void) + this.disposeMutation = requireActiveVfs().subscribe((mutation) => { + if (!this.matches(mutation)) return + const eventType = mutation.kind === 'write' && !mutation.entryChanged || mutation.kind === 'chmod' + ? 'change' + : 'rename' + const filename = this.filename(mutation.path) + queueMicrotask(() => { + if (this.closed) return + runWithAsyncContext(context, () => { this.emit('change', eventType, filename) }) + }) + }) + this.signal = options.signal + this.onAbort = options.signal === undefined ? undefined : () => { this.close() } + if (options.signal?.aborted === true) { + this.disposeMutation() + this.closed = true + throw abortError(options.signal.reason) + } + options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) + } + + private matches(mutation: VfsMutation): boolean { + if (mutation.path === this.target) return true + if (mutation.kind === 'remove' && contains(mutation.path, this.target)) return true + if (!this.directory || !contains(this.target, mutation.path)) return false + if (this.options.recursive === true) return true + const child = relative(this.target, mutation.path) + return child !== '' && !child.startsWith('..') && !child.includes(sep) + } + + private filename(path: string): string | Buffer { + const relativePath = relative(this.target, path) + const value = this.directory && contains(this.target, path) + ? this.options.recursive === true ? relativePath : relativePath.split(sep)[0] ?? '' + : basename(this.target) + return this.options.encoding === 'buffer' ? Buffer.from(value) : value + } + + /** Stop observing and publish `close` once. */ + close(): void { + if (this.closed) return + this.closed = true + this.disposeMutation() + if (this.onAbort !== undefined) this.signal?.removeEventListener('abort', this.onAbort) + queueMicrotask(() => { this.emit('close') }) + } + + /** + * Mark this watcher as process-liveness-bearing. + * @returns This watcher. + */ + ref(): this { + this.referenced = true + return this + } + + /** + * Clear the process-liveness flag; dedicated Workers have no ref-counted event loop. + * @returns This watcher. + */ + unref(): this { + this.referenced = false + return this + } + + /** + * Read the retained process-liveness flag. + * @returns Whether this watcher is marked as keeping its owner alive. + */ + hasRef(): boolean { + return this.referenced + } +} + +/** + * Watch one path through the active VFS. + * @param path - File or directory path. + * @param optionsOrListener - Watch options, encoding, or the change listener. + * @param maybeListener - Change listener when the second argument carries options. + * @returns The closeable watcher. + */ +export function watch( + path: PathArg, + optionsOrListener?: WatchOptions | BufferEncoding | 'buffer' | WatchListener, + maybeListener?: WatchListener, +): FSWatcher { + const options: WatchOptions = typeof optionsOrListener === 'object' + ? optionsOrListener + : typeof optionsOrListener === 'string' ? { encoding: optionsOrListener } : {} + const listener = typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener + const target = asPath(path) + const stats = requireActiveVfs().statSync(target) + return new FSWatcher(target, stats.isDirectory(), options, listener) +} + +/** `fs.StatWatcher` returned from `watchFile`. */ +export class StatWatcher extends EventEmitter { + private readonly disposeMutation: () => void + private timer: ReturnType | undefined + private previous: WatchStats + private stopped = false + private referenced: boolean + private readonly context: ReturnType + private readonly interval: number + private readonly bigint: boolean + + constructor(readonly path: string, options: WatchFileOptions) { + super() + this.referenced = options.persistent ?? true + this.interval = options.interval ?? 5007 + this.bigint = options.bigint ?? false + this.previous = statOrMissing(path, this.bigint) + this.context = captureAsyncContext() + this.disposeMutation = requireActiveVfs().subscribe((mutation) => { + if (overlaps(path, mutation.path)) this.schedule() + }) + if (!this.previous.isFile() && !this.previous.isDirectory()) this.schedule(true) + } + + private schedule(initialMissing = false): void { + if (this.stopped || this.timer !== undefined) return + this.timer = setTimeout(() => { + this.timer = undefined + if (this.stopped) return + const current = statOrMissing(this.path, this.bigint) + const previous = this.previous + this.previous = current + if (initialMissing || statsChanged(current, previous)) { + runWithAsyncContext(this.context, () => { this.emit('change', current, previous) }) + } + }, this.interval) + if (!this.referenced) timerUnref(this.timer) + } + + /** Stop polling and release the VFS subscription. */ + stop(): void { + if (this.stopped) return + this.stopped = true + this.disposeMutation() + if (this.timer !== undefined) clearTimeout(this.timer) + this.timer = undefined + this.emit('stop') + } + + /** Alias used by callers treating the watcher as a closeable handle. */ + close(): void { + this.stop() + } + + /** + * Mark this watcher as process-liveness-bearing. + * @returns This watcher. + */ + ref(): this { + this.referenced = true + if (this.timer !== undefined) timerRef(this.timer) + return this + } + + /** + * Mark this watcher as not keeping its owner alive. + * @returns This watcher. + */ + unref(): this { + this.referenced = false + if (this.timer !== undefined) timerUnref(this.timer) + return this + } + + /** + * Read the retained process-liveness flag. + * @returns Whether this watcher is marked as keeping its owner alive. + */ + hasRef(): boolean { + return this.referenced + } + +} + +type RefTimer = { ref?: () => unknown; unref?: () => unknown } + +/** Browser timers are numeric; Node timers expose optional liveness methods. */ +const timerRef = (timer: ReturnType): void => { + ;(timer as unknown as RefTimer).ref?.() +} + +/** Browser timers are numeric; Node timers expose optional liveness methods. */ +const timerUnref = (timer: ReturnType): void => { + ;(timer as unknown as RefTimer).unref?.() +} + +const statWatchers = new Map() + +/** + * Register a stat-poll watcher for one path. + * @param path - File or directory path, including a currently missing path. + * @param optionsOrListener - Polling options or the change listener. + * @param maybeListener - Change listener when the second argument carries options. + * @returns The path's shared stat watcher. + */ +export function watchFile( + path: PathArg, + optionsOrListener: WatchFileOptions | StatListener, + maybeListener?: StatListener, +): StatWatcher { + const options = typeof optionsOrListener === 'function' ? {} : optionsOrListener + const listener = typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener + if (listener === undefined) throw new TypeError('The "listener" argument must be of type function') + const target = asPath(path) + let watcher = statWatchers.get(target) + if (watcher === undefined) { + watcher = new StatWatcher(target, options) + statWatchers.set(target, watcher) + watcher.once('stop', () => { statWatchers.delete(target) }) + } + watcher.on('change', listener as (...args: unknown[]) => void) + return watcher +} + +/** + * Remove one listener or every listener for a path. + * @param path - Watched path. + * @param listener - Specific registration to remove; omission removes all registrations. + */ +export function unwatchFile(path: PathArg, listener?: StatListener): void { + const target = asPath(path) + const watcher = statWatchers.get(target) + if (watcher === undefined) return + if (listener === undefined) watcher.removeAllListeners('change') + else watcher.removeListener('change', listener as (...args: unknown[]) => void) + if (watcher.listenerCount('change') === 0) watcher.stop() +} + +/** + * Create the promise-based watch iterator over the callback watcher. + * @param path - File or directory path. + * @param options - Watch options and cancellation signal. + * @returns An iterator of change records that closes its watcher on return or failure. + */ +export function watchAsync( + path: PathArg, + options: WatchOptions = {}, +): AsyncIterableIterator<{ eventType: 'rename' | 'change'; filename: string | Buffer | null }> { + type WatchEvent = { eventType: 'rename' | 'change'; filename: string | Buffer | null } + type Waiting = { + resolve(result: IteratorResult): void + reject(reason: unknown): void + } + const queued: WatchEvent[] = [] + const waiting: Waiting[] = [] + let watcher: FSWatcher | undefined + let failure: Error | undefined + let closed = false + + const settleFailure = (reason: unknown): void => { + if (failure !== undefined || closed) return + const error = reason instanceof Error ? reason : new Error(String(reason)) + failure = error + watcher?.close() + for (const pending of waiting.splice(0)) pending.reject(error) + } + const onAbort = (): void => { settleFailure(abortError(options.signal?.reason)) } + const start = (): void => { + if (watcher !== undefined || closed || failure !== undefined) return + try { + watcher = watch(path, options, (eventType, filename) => { + const event = { eventType, filename } + const pending = waiting.shift() + if (pending === undefined) queued.push(event) + else pending.resolve({ done: false, value: event }) + }) + watcher.on('error', settleFailure) + options.signal?.addEventListener('abort', onAbort, { once: true }) + } catch (error) { + settleFailure(error) + } + } + const close = (): void => { + if (closed) return + closed = true + queued.length = 0 + options.signal?.removeEventListener('abort', onAbort) + watcher?.close() + for (const pending of waiting.splice(0)) pending.resolve({ done: true, value: undefined }) + } + + return { + [Symbol.asyncIterator]() { + return this + }, + next(): Promise> { + start() + if (failure !== undefined) return Promise.reject(failure) + const event = queued.shift() + if (event !== undefined) return Promise.resolve({ done: false, value: event }) + if (closed) return Promise.resolve({ done: true, value: undefined }) + return new Promise>((resolve, reject) => { waiting.push({ resolve, reject }) }) + }, + return(): Promise> { + close() + return Promise.resolve({ done: true, value: undefined }) + }, + throw(reason?: unknown): Promise> { + close() + // AsyncIterator.throw forwards the caller's exact reason, including non-Error values. + return Promise.reject(reason) + }, + } +} diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index 848d04436b..0aea178db4 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -2,19 +2,20 @@ * `node:fs` bridge over the worker's in-memory VFS. `MemoryVfs` owns paths, * bytes, the directory tree, and Node's error codes; this module adds only what * is Node-API-shaped and not VFS business: Buffer results, `Dirent` objects, - * file descriptors, `mkdtemp`, access checks, inert watches, and the promise face. + * file descriptors, `mkdtemp`, access checks, watchers, streams, and the promise face. */ import { requireActiveVfs } from '../../../storage/active.ts' -import type { MemoryVfs } from '../../../storage/memory.ts' -import type { VfsBigIntStats, VfsStatOptions, VfsStats, VfsWriteOptions } from '../../../storage/types.ts' +import type { Vfs, VfsBigIntStats, VfsStatOptions, VfsStats, VfsWriteOptions } from '../../../storage/types.ts' import { Buffer } from 'buffer' +import { Readable, Writable } from './stream.ts' import { dirname } from './path.ts' +import { + FSWatcher, StatWatcher, unwatchFile, watch, watchAsync, watchFile, +} from './fs-watch.ts' -const vfs = (): MemoryVfs => requireActiveVfs() +const vfs = (): Vfs => requireActiveVfs() -const notImplemented = (method: string, subject: string): never => { - throw new Error(`web-preview: node:fs.${method} is not implemented in the worker host (${subject})`) -} +export { FSWatcher, StatWatcher, unwatchFile, watch, watchFile } type PathArg = string | URL | Uint8Array @@ -150,6 +151,29 @@ export function statSync(path: PathArg, options?: VfsStatOptions): VfsStats | Vf return vfs().statSync(asPath(path), options) } +/** + * Read stats through Node's callback form. + * @param path - Path to stat. + * @param optionsOrCallback - Stat options or the completion callback. + * @param maybeCallback - Completion callback when options are present. + */ +export function stat( + path: PathArg, + optionsOrCallback: VfsStatOptions | ((error: NodeJS.ErrnoException | null, stats?: VfsStats | VfsBigIntStats) => void), + maybeCallback?: (error: NodeJS.ErrnoException | null, stats?: VfsStats | VfsBigIntStats) => void, +): void { + const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback + const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback + if (callback === undefined) throw new TypeError('The "callback" argument must be of type function') + queueMicrotask(() => { + try { + callback(null, statSync(path, options)) + } catch (error) { + callback(error as NodeJS.ErrnoException) + } + }) +} + /** * Change an entry's permission bits; stat reads back exactly what was set. * @param path - the path. @@ -169,6 +193,20 @@ export function lstatSync(path: PathArg, options?: VfsStatOptions): VfsStats | V return statSync(path, options) } +/** + * Read link stats through Node's callback form; this symlink-free VFS delegates to stat. + * @param path - Path to stat. + * @param optionsOrCallback - Stat options or the completion callback. + * @param maybeCallback - Completion callback when options are present. + */ +export function lstat( + path: PathArg, + optionsOrCallback: VfsStatOptions | ((error: NodeJS.ErrnoException | null, stats?: VfsStats | VfsBigIntStats) => void), + maybeCallback?: (error: NodeJS.ErrnoException | null, stats?: VfsStats | VfsBigIntStats) => void, +): void { + stat(path, optionsOrCallback, maybeCallback) +} + /** * Canonical path (normalization only: the image is symlink-free). * @param path - the path. @@ -265,9 +303,10 @@ let nextFd = 3 * @param path - file path. * @param flags - Node flag string: 'r', 'w', 'a', with optional '+' and the * exclusive 'x' (create-only) modifier. + * @param mode - creation permission bits. * @returns the descriptor. */ -export function openSync(path: PathArg, flags = 'r'): number { +export function openSync(path: PathArg, flags = 'r', mode?: number): number { const target = asPath(path) const exists = vfs().existsSync(target) if (flags.includes('x') && exists) { @@ -277,7 +316,9 @@ export function openSync(path: PathArg, flags = 'r'): number { throw error } if (flags.startsWith('r')) vfs().realpathSync(target) - else if (flags.startsWith('w') || !exists) vfs().writeFileSync(target, new Uint8Array(0)) + else if (flags.startsWith('w') || !exists) { + vfs().writeFileSync(target, new Uint8Array(0), mode === undefined ? undefined : { mode }) + } const fd = nextFd++ openFiles.set(fd, { path: target, position: 0, append: flags.startsWith('a') }) return fd @@ -356,8 +397,8 @@ export function linkSync(from: PathArg, to: PathArg): void { /** * Open file handle (`fs.FileHandle` subset): the atomic-write and durability - * pair the storage backends use. `sync`/`datasync` are no-ops — an in-memory - * filesystem has nothing to flush, and a worker reload loses it either way. + * pair the storage backends use. `sync`/`datasync` settle the active VFS's + * optional write-behind sink. */ export interface FileHandle { readonly fd: number @@ -377,13 +418,14 @@ export interface FileHandle { * helpers do before an fsync. * @param path - file or directory path. * @param flags - Node flag string. + * @param mode - creation permission bits. * @returns the handle. */ -export function openHandleSync(path: PathArg, flags = 'r'): FileHandle { +export function openHandleSync(path: PathArg, flags = 'r', mode?: number): FileHandle { const target = asPath(path) const directory = vfs().existsSync(target) && vfs().statSync(target).isDirectory() const append = flags.startsWith('a') - const fd = directory ? -1 : openSync(target, flags) + const fd = directory ? -1 : openSync(target, flags, mode) return { fd, readFile: async (options?: EncodingOption) => readFileSync(target, options), @@ -403,56 +445,251 @@ export function openHandleSync(path: PathArg, flags = 'r'): FileHandle { truncate: async (length = 0) => { writeFileSync(target, bytesOf(target).subarray(0, length)) }, - sync: async () => { /* memory-backed: nothing to flush */ }, - datasync: async () => { /* memory-backed: nothing to flush */ }, + sync: async () => { await vfs().flush() }, + datasync: async () => { await vfs().flush() }, close: async () => { if (fd !== -1) closeSync(fd) }, } } -/** - * Watch registration refuses loudly, and NOT because watching is hard. - * - * An inert watcher would not serve this caller. `skill-filesystem` does not - * merely register a listener — `openStableWatcher` opens a watcher and then - * loops until two consecutive mode probes agree, so a watcher that reports - * success and never fires leaves `observeRoots()` awaiting forever: the skill - * catalog RPC never answers and the worker's single thread stops serving `/api` - * for the rest of the session. A refusal instead fails that path fast, which the - * provider already handles by returning an incomplete observation. - * - * So the family split is about what the CALLER does with the capability, not - * about the capability: a listener registration tolerates absence, a watcher - * whose progress is awaited does not. - * @param path - the path a caller wanted watched, named in the refusal. - * @returns Never — it throws naming the unavailable member. - */ -export function watchFile(path: PathArg): never { - return notImplemented('watchFile', asPath(path)) +/** Options supported by the VFS-backed read stream. */ +export interface ReadStreamOptions { + flags?: string + encoding?: BufferEncoding | null + autoClose?: boolean + emitClose?: boolean + start?: number + end?: number + highWaterMark?: number + signal?: AbortSignal } -/** Watch removal; teardown paths call it unconditionally, and nothing was watched. */ -export function unwatchFile(): void { - // No watch was ever established. +/** Options supported by the VFS-backed write stream. */ +export interface WriteStreamOptions { + flags?: string + encoding?: BufferEncoding | null + mode?: number + autoClose?: boolean + emitClose?: boolean + start?: number + highWaterMark?: number + signal?: AbortSignal +} + +const aborted = (reason?: unknown): Error => { + const error = new Error('The operation was aborted', { cause: reason }) as Error & { code: string } + error.name = 'AbortError' + error.code = 'ABORT_ERR' + return error +} + +/** Read stream over one VFS file. */ +export class ReadStream extends Readable { + /** Resolved path opened by this stream. */ + readonly path: string + /** Open descriptor, or null before open and after close. */ + fd: number | null = null + /** Whether the descriptor is still waiting to open. */ + pending = true + /** Bytes delivered by this stream. */ + bytesRead = 0 + private readonly start: number + private readonly end: number + private readonly flags: string + private readonly signal: AbortSignal | undefined + private readonly onAbort: (() => void) | undefined + private position: number + + constructor(path: PathArg, options: ReadStreamOptions = {}) { + super({ + autoDestroy: options.autoClose ?? true, + emitClose: options.emitClose ?? true, + highWaterMark: options.highWaterMark ?? 64 * 1024, + }) + this.path = asPath(path) + this.start = options.start ?? 0 + this.end = options.end ?? Number.POSITIVE_INFINITY + this.flags = options.flags ?? 'r' + this.position = this.start + this.signal = options.signal + this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(aborted(options.signal?.reason)) } + if (options.encoding !== undefined && options.encoding !== null) this.setEncoding(options.encoding) + options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) + } + + override _construct(callback: (error?: Error | null) => void): void { + if (this.start < 0 || this.end < this.start) { + callback(new RangeError('The value of "start" is out of range')) + return + } + if (this.signal?.aborted === true) { + callback(aborted(this.signal.reason)) + return + } + try { + this.fd = openSync(this.path, this.flags) + this.pending = false + this.emit('open', this.fd) + this.emit('ready') + callback() + } catch (error) { + callback(error as Error) + } + } + + override _read(size: number): void { + if (this.fd === null) return + const remaining = this.end === Number.POSITIVE_INFINITY ? size : Math.min(size, this.end - this.position + 1) + if (remaining <= 0) { + this.push(null) + return + } + const buffer = Buffer.allocUnsafe(remaining) + let count: number + try { + count = readSync(this.fd, buffer, 0, remaining, this.position) + } catch (error) { + this.destroy(error as Error) + return + } + if (count === 0) { + this.push(null) + return + } + this.position += count + this.bytesRead += count + this.push(buffer.subarray(0, count)) + } + + override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { + this.signal?.removeEventListener('abort', this.onAbort as () => void) + if (this.fd !== null) closeSync(this.fd) + this.fd = null + this.pending = false + callback(error) + } + + /** + * Close the stream and release its descriptor. + * @param callback - Optional completion callback after `close`. + */ + close(callback?: (error?: NodeJS.ErrnoException | null) => void): void { + if (callback !== undefined) this.once('close', () => { callback(null) }) + this.destroy() + } +} + +/** Writable stream committing chunks through the VFS file-descriptor face. */ +export class WriteStream extends Writable { + /** Resolved path opened by this stream. */ + readonly path: string + /** Open descriptor, or null before open and after close. */ + fd: number | null = null + /** Whether the descriptor is still waiting to open. */ + pending = true + /** Bytes committed by this stream. */ + bytesWritten = 0 + private readonly flags: string + private readonly mode: number | undefined + private readonly start: number | undefined + private readonly signal: AbortSignal | undefined + private readonly onAbort: (() => void) | undefined + + constructor(path: PathArg, options: WriteStreamOptions = {}) { + super({ + autoDestroy: options.autoClose ?? true, + decodeStrings: true, + defaultEncoding: options.encoding ?? 'utf8', + emitClose: options.emitClose ?? true, + highWaterMark: options.highWaterMark ?? 64 * 1024, + }) + this.path = asPath(path) + this.flags = options.flags ?? 'w' + this.mode = options.mode + this.start = options.start + this.signal = options.signal + this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(aborted(options.signal?.reason)) } + options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) + } + + override _construct(callback: (error?: Error | null) => void): void { + if (this.start !== undefined && this.start < 0) { + callback(new RangeError('The value of "start" is out of range')) + return + } + if (this.signal?.aborted === true) { + callback(aborted(this.signal.reason)) + return + } + try { + this.fd = openSync(this.path, this.flags, this.mode) + if (this.start !== undefined) fileOf(this.fd, 'write').position = this.start + this.pending = false + this.emit('open', this.fd) + this.emit('ready') + callback() + } catch (error) { + callback(error as Error) + } + } + + override _write( + chunk: string | Uint8Array, + encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + try { + if (this.fd === null) throw new Error('EBADF: bad file descriptor, write') + const data = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk + this.bytesWritten += writeSync(this.fd, data) + callback() + } catch (error) { + callback(error as Error) + } + } + + override _destroy(error: Error | null, callback: (error: Error | null) => void): void { + this.signal?.removeEventListener('abort', this.onAbort as () => void) + closeDescriptor(this.fd) + this.fd = null + this.pending = false + callback(error) + } + + /** + * Close the stream and release its descriptor. + * @param callback - Optional completion callback after `close`. + */ + close(callback?: (error?: NodeJS.ErrnoException | null) => void): void { + if (callback !== undefined) this.once('close', () => { callback(null) }) + this.destroy() + } +} + +/** Close a stream-owned descriptor when it has opened successfully. */ +function closeDescriptor(fd: number | null): void { + if (fd !== null) closeSync(fd) } /** - * Streaming read is unavailable: node:stream has no implementation here. - * @param path - the path a caller wanted streamed, named in the refusal. - * @returns Never — it throws naming the unavailable member. + * Create a Node-compatible readable file stream over the VFS. + * @param path - File path. + * @param options - Encoding, range, open, buffer, and abort options. + * @returns The readable file stream. */ -export function createReadStream(path: PathArg): never { - return notImplemented('createReadStream', asPath(path)) +export function createReadStream(path: PathArg, options?: ReadStreamOptions | BufferEncoding): ReadStream { + return new ReadStream(path, typeof options === 'string' ? { encoding: options } : options) } /** - * Streaming write counterpart of {@link createReadStream}. - * @param path - the path a caller wanted streamed, named in the refusal. - * @returns Never — it throws naming the unavailable member. + * Create a Node-compatible writable file stream over the VFS. + * @param path - File path. + * @param options - Encoding, open, buffer, and abort options. + * @returns The writable file stream. */ -export function createWriteStream(path: PathArg): never { - return notImplemented('createWriteStream', asPath(path)) +export function createWriteStream(path: PathArg, options?: WriteStreamOptions | BufferEncoding): WriteStream { + return new WriteStream(path, typeof options === 'string' ? { encoding: options } : options) } /** Open directory handle (`fs.Dir` subset): iteration plus the close pair. */ @@ -538,11 +775,12 @@ export const promises = { // The VFS has no inodes, so a hard link is a byte copy: the caller's contract // is only that both names read the same content until one is removed. link: async (from: PathArg, to: PathArg): Promise => { linkSync(from, to) }, - open: async (path: PathArg, flags?: string): Promise => openHandleSync(path, flags), + open: async (path: PathArg, flags?: string, mode?: number): Promise => openHandleSync(path, flags, mode), opendir: async (path: PathArg): Promise => opendirSync(path), truncate: async (path: PathArg, length = 0): Promise => { writeFileSync(path, bytesOf(asPath(path)).subarray(0, length)) }, + watch: watchAsync, constants, } satisfies Partial> @@ -559,10 +797,11 @@ export const __esModule = true * the subsets the host tree reads. */ type OwnSignature = - | 'constants' | 'promises' | 'Dirent' + | 'constants' | 'promises' | 'Dirent' | 'FSWatcher' | 'StatWatcher' | 'ReadStream' | 'WriteStream' | 'readFileSync' | 'writeFileSync' | 'appendFileSync' | 'statSync' | 'lstatSync' | 'realpathSync' | 'readdirSync' | 'mkdirSync' | 'mkdtempSync' | 'rmSync' | 'opendirSync' - | 'openSync' | 'readSync' | 'writeSync' + | 'openSync' | 'readSync' | 'writeSync' | 'stat' | 'lstat' | 'watch' | 'watchFile' | 'unwatchFile' + | 'createReadStream' | 'createWriteStream' /** * The `node:fs` declarations this module stands in for. Every other member is @@ -574,10 +813,10 @@ type NodeFace = Partial> /** CommonJS default export: the members `require()` hands a caller of this module. */ export default { - constants, promises, Dirent, - readFileSync, writeFileSync, appendFileSync, existsSync, statSync, lstatSync, realpathSync, chmodSync, + constants, promises, Dirent, FSWatcher, StatWatcher, ReadStream, WriteStream, + readFileSync, writeFileSync, appendFileSync, existsSync, statSync, stat, lstatSync, lstat, realpathSync, chmodSync, readdirSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, renameSync, accessSync, opendirSync, openHandleSync, linkSync, - openSync, readSync, writeSync, closeSync, watchFile, unwatchFile, + openSync, readSync, writeSync, closeSync, watch, watchFile, unwatchFile, createReadStream, createWriteStream, } satisfies NodeFace diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts index a49ca4a22f..c83ca26b6d 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts @@ -9,7 +9,7 @@ import { Dirent, promises } from '../fs.ts' /** The promise members of the VFS bridge, as `node:fs/promises` names them. */ export const { readFile, writeFile, appendFile, mkdir, mkdtemp, readdir, stat, lstat, realpath, rm, unlink, - rename, access, chmod, cp, link, open, opendir, truncate, constants, + rename, access, chmod, cp, link, open, opendir, truncate, watch, constants, } = promises export { Dirent } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts new file mode 100644 index 0000000000..757961a42a --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts @@ -0,0 +1,82 @@ +/** + * `node:stream` compatibility backed by readable-stream's browser build. + * + * readable-stream is the userland copy of Node's stream implementation. The + * worker owns only platform adapters such as VFS file streams; stream state, + * backpressure, async iteration, abort handling, and event ordering stay in + * that maintained implementation. + */ +import Stream from 'readable-stream' + +type StreamRuntime = typeof import('node:stream') & { + compose(...streams: unknown[]): unknown + destroy(stream: unknown, error?: Error): void + isDisturbed(stream: unknown): boolean +} + +type StreamStatics = typeof import('node:stream').Stream & { + getDefaultHighWaterMark(objectMode: boolean): number + isDestroyed(stream: unknown): boolean | null + isWritable(stream: unknown): boolean | null + setDefaultHighWaterMark(objectMode: boolean, value: number): void +} + +const nodeStream = Stream as unknown as StreamRuntime + +const { + Duplex, PassThrough, Readable, Stream: StreamBase, Transform, Writable, + addAbortSignal, compose, destroy, finished, isDisturbed, isErrored, isReadable, pipeline, promises, +} = nodeStream +const streamStatics = StreamBase as unknown as StreamStatics +const { + getDefaultHighWaterMark, isDestroyed, isWritable, setDefaultHighWaterMark, +} = streamStatics + +// readable-stream tracks Node 18's 16 KiB byte default; this repository runs +// Node 22+, whose generic and file streams use 64 KiB. +if (getDefaultHighWaterMark(false) !== 64 * 1024) setDefaultHighWaterMark(false, 64 * 1024) + +/** + * Test whether a value is an ArrayBuffer view. + * @param value - Candidate value. + * @returns Whether the value is a typed-array or DataView instance. + */ +const _isArrayBufferView = (value: unknown): value is ArrayBufferView => ArrayBuffer.isView(value) + +/** Default-import namespace carrying Node's stream class and static helpers. */ +const streamDefault = Object.assign(Stream, { + _isArrayBufferView, + getDefaultHighWaterMark, + isDestroyed, + isWritable, + setDefaultHighWaterMark, +}) + +export { + Duplex, + PassThrough, + Readable, + StreamBase as Stream, + Transform, + Writable, + addAbortSignal, + compose, + destroy, + finished, + getDefaultHighWaterMark, + _isArrayBufferView, + isDestroyed, + isDisturbed, + isErrored, + isReadable, + isWritable, + pipeline, + promises, + setDefaultHighWaterMark, +} + +/** CommonJS interop marker consumed by the worker module loader. */ +export const __esModule = true + +/** CommonJS-compatible namespace for default imports. */ +export default streamDefault diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts deleted file mode 100644 index 7c30396bec..0000000000 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * `node:stream` stub. Every harness import of this module in the reachable tree - * is type-only (`Duplex`/`Readable`/`Writable` annotations), so nothing here runs - * unless a value import appears; then it says so. - */ -import { notImplementedFail } from '../../notImplementedFail.ts' - -const MODULE = 'node:stream' - -/** Readable stream (unavailable; use WHATWG ReadableStream). */ -export const Readable: typeof import('node:stream').Readable = notImplementedFail(MODULE, 'Readable') - -/** Writable stream (unavailable). */ -export const Writable: typeof import('node:stream').Writable = notImplementedFail(MODULE, 'Writable') - -/** Duplex stream (unavailable). */ -export const Duplex: typeof import('node:stream').Duplex = notImplementedFail(MODULE, 'Duplex') - -/** Transform stream (unavailable). */ -export const Transform: typeof import('node:stream').Transform = notImplementedFail(MODULE, 'Transform') - -/** PassThrough stream (unavailable). */ -export const PassThrough: typeof import('node:stream').PassThrough = notImplementedFail(MODULE, 'PassThrough') - -/** Pipeline helper (unavailable). */ -export const pipeline: typeof import('node:stream').pipeline = notImplementedFail(MODULE, 'pipeline') - -/** Finished helper (unavailable). */ -export const finished: typeof import('node:stream').finished = notImplementedFail(MODULE, 'finished') - -/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ -export const __esModule = true - -/** The `node:stream` declarations this module stands in for. */ -type NodeFace = Partial - -/** CommonJS default export: the members `require()` hands a caller of this module. */ -export default { Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished } satisfies NodeFace diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index f7008b518d..a2260d9831 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -32,6 +32,7 @@ import * as nodeModule from './builtin_modules/implemented/module.ts' import * as nodeOs from './builtin_modules/implemented/os.ts' import * as nodePath from './builtin_modules/implemented/path.ts' import * as nodePerfHooks from './builtin_modules/implemented/perf_hooks.ts' +import * as nodeStream from './builtin_modules/implemented/stream.ts' import * as nodeTimersPromises from './builtin_modules/implemented/timers/promises.ts' import * as nodeUrl from './builtin_modules/implemented/url.ts' import * as nodeUtil from './builtin_modules/implemented/util.ts' @@ -40,12 +41,9 @@ import * as nodeZlib from './builtin_modules/implemented/zlib.ts' import * as nodeChildProcess from './builtin_modules/implemented/child_process.ts' import * as nodeNet from './builtin_modules/mock/net.ts' import * as nodeSqlite from './builtin_modules/mock/sqlite.ts' -import * as nodeStream from './builtin_modules/mock/stream.ts' import * as nodeVm from './builtin_modules/mock/vm.ts' import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts' -import * as chokidar from './external_packages/chokidar.ts' import * as koffi from './external_packages/koffi.ts' -import * as landlockRun from './external_packages/node-addon-landlock-run.ts' import * as nodePty from './external_packages/node-pty.ts' import * as piAi from './external_packages/pi-ai.ts' import * as ripgrep from './external_packages/ripgrep.ts' @@ -83,14 +81,12 @@ const BUILTINS: Record = { /** External npm packages replaced wholesale (structural not-implemented stubs and fakes). */ const EXTERNALS: Record = { - 'chokidar': () => chokidar, 'koffi': () => koffi, 'sharp': () => sharp, 'node-pty': () => nodePty, 'ws': () => ws, '@vscode/ripgrep': () => ripgrep, '@earendil-works/pi-ai': () => piAi, - '@deepseek-ai/node-addon-landlock-run': () => landlockRun, } /** diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts b/packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts deleted file mode 100644 index b8c96ccd65..0000000000 --- a/packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * `chokidar` stub: a constructible watcher that never fires. Settings and - * credentials call `watch()` unconditionally in `[Service.init]`, and the - * in-memory VFS has no external writer, so "no events" is the truth here rather - * than a degradation. - */ - -/** No-op watcher with chokidar's chainable face. */ -export class FSWatcher { - /** - * Register a listener; no event is ever emitted. - * @returns this watcher. - */ - on(): this { - return this - } - - /** - * Register a one-shot listener; no event is ever emitted. - * @returns this watcher. - */ - once(): this { - return this - } - - /** - * Add paths to the (inert) watch set. - * @returns this watcher. - */ - add(): this { - return this - } - - /** - * Remove paths from the (inert) watch set. - * @returns this watcher. - */ - unwatch(): this { - return this - } - - /** - * Watched paths, as chokidar reports them. - * @returns An empty record; nothing is ever watched. - */ - getWatched(): Record { - return {} - } - - /** Close the watcher. */ - async close(): Promise { - // Nothing was ever watched. - } -} - -/** - * Create an inert watcher. - * @returns the watcher. - */ -export function watch(): FSWatcher { - return new FSWatcher() -} - -/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ -export const __esModule = true - -/** CommonJS default export: the members `require()` hands a caller of this module. */ -export default { watch, FSWatcher } diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts b/packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts deleted file mode 100644 index b963837cb4..0000000000 --- a/packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * `@deepseek-ai/node-addon-landlock-run` stub: the Landlock launcher. Sandboxing - * is part of the declared excluded surface, so `sandbox-local` mounts with the - * launcher path and probe present and fails when it tries to confine a process. - */ -import { notImplementedFail } from '../notImplementedFail.ts' - -const MODULE = '@deepseek-ai/node-addon-landlock-run' - -/** Launcher executable name, read at module scope by sandbox-local. */ -export const LAUNCHER_BIN = 'landlock-run' - -/** Exit code the launcher reports when confinement itself fails. */ -export const LAUNCHER_FAILURE_EXIT = 126 - -/** - * Path of the launcher binary; nothing in a browser can execute it. - * @returns The image path consumers read before failing on their own terms. - */ -export function launcherPath(): string { - return `/dsh/bin/${LAUNCHER_BIN}` -} - -/** Landlock availability probe (unavailable). */ -export const probe = notImplementedFail(MODULE, 'probe') - -/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ -export const __esModule = true - -/** CommonJS default export: the members `require()` hands a caller of this module. */ -export default { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, launcherPath, probe } diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts index 6332f8af02..528e6f6885 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts @@ -8,10 +8,8 @@ /** External packages served from the worker bundle instead of the VFS. */ export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [ - '@deepseek-ai/node-addon-landlock-run', '@earendil-works/pi-ai', '@vscode/ripgrep', - 'chokidar', 'koffi', 'node-pty', 'sharp', diff --git a/packages/experimental/webworker-runtime/src/shell/fs-access.ts b/packages/experimental/webworker-runtime/src/shell/fs-access.ts index 64d2a6a895..48da48302a 100644 --- a/packages/experimental/webworker-runtime/src/shell/fs-access.ts +++ b/packages/experimental/webworker-runtime/src/shell/fs-access.ts @@ -57,7 +57,8 @@ export function describeFailure(program: string, path: string, error: unknown): * @returns the error to throw. */ export function filesystemError(code: string, syscall: string, path: string): VfsError { - const error = new Error(`${code}: ${syscall} failed, ${syscall} '${path}'`) as VfsError + const reason = code === 'EACCES' ? 'permission denied' : `${syscall} failed` + const error = new Error(`${code}: ${reason}, ${syscall} '${path}'`) as VfsError error.code = code error.path = path error.syscall = syscall @@ -75,7 +76,6 @@ function statsOf(stats: VfsStats): ShellStats { */ export function hostFileSystem(): ShellFileSystem { const vfs = (): ReturnType => requireActiveVfs() - // oxlint-disable-next-line typescript/require-await -- async face, in-memory backend; see the note below. const stat = async (path: string): Promise => { try { return statsOf(vfs().statSync(path) as VfsStats) @@ -87,7 +87,6 @@ export function hostFileSystem(): ShellFileSystem { } // Several members take no await: the face is asynchronous because a process // worker's filesystem is, while this backend answers from memory. - /* oxlint-disable typescript/require-await -- see the note above. */ return { stat, list: async (path: string): Promise => { @@ -116,5 +115,4 @@ export function hostFileSystem(): ShellFileSystem { vfs().renameSync(from, to) }, } - /* oxlint-enable typescript/require-await */ } diff --git a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts new file mode 100644 index 0000000000..319ee6b983 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts @@ -0,0 +1,188 @@ +/** Landlock launcher parsing and per-process VFS enforcement for the worker shell. */ +import { resolve } from '../../module-system/posix-path.ts' +import { DSH_TMP } from '../../storage/paths.ts' +import { filesystemError } from '../fs-access.ts' +import type { ShellDirent, ShellFileSystem, ShellStats } from '../types.ts' +import type { VirtualExecutable, VirtualExecutableExit } from './virtual-executables.ts' + +/** Parsed invocation of the native launcher's unchanged argv grammar. */ +export type LandlockInvocation = + | { readonly kind: 'probe' } + | { + readonly kind: 'run' + readonly readOnly: readonly string[] + readonly readWrite: readonly string[] + readonly argv: readonly string[] + } + +/** Launcher-owned failure; callers print its message with the `landlock-run:` prefix. */ +export class LandlockLauncherError extends Error {} + +/** + * Parse the native launcher's argv grammar. + * @param args - Arguments after the launcher executable. + * @returns A probe or confined-run request. + */ +export function parseLandlockArguments(args: readonly string[]): LandlockInvocation { + const readOnly: string[] = [] + const readWrite: string[] = [] + for (let index = 0; index < args.length;) { + const argument = args[index] as string + if (argument === '--probe') { + if (args.length !== 1) throw new LandlockLauncherError('usage error: --probe takes no other arguments') + return { kind: 'probe' } + } + if (argument === '--ro' || argument === '--rw') { + const path = args[index + 1] + if (path === undefined) throw new LandlockLauncherError(`usage error: ${argument} requires a path`) + ;(argument === '--ro' ? readOnly : readWrite).push(path) + index += 2 + continue + } + if (argument === '--') { + const argv = args.slice(index + 1) + if (argv.length === 0) throw new LandlockLauncherError('usage error: missing `-- ...` command') + return { kind: 'run', readOnly, readWrite, argv } + } + throw new LandlockLauncherError(`usage error: unknown argument: ${argument}`) + } + throw new LandlockLauncherError('usage error: missing `-- ...` command') +} + +/** Map the host launcher's temp path into the Worker VFS. */ +function vfsPath(path: string, cwd: string): string { + const absolute = resolve(cwd, path) + if (absolute === '/tmp') return DSH_TMP + if (absolute.startsWith('/tmp/')) return `${DSH_TMP}${absolute.slice('/tmp'.length)}` + return absolute +} + +/** Whether a normalized path is the root itself or one of its descendants. */ +function contains(root: string, path: string): boolean { + return root === '/' || path === root || path.startsWith(`${root}/`) +} + +/** Throw the denial dialect consumed by `dsh-bash-sandbox`. */ +function deny(syscall: string, path: string): never { + throw filesystemError('EACCES', syscall, path) +} + +/** Stats for the virtual `/dev/null` file. */ +const NULL_STATS: ShellStats = { directory: false, size: 0, mtimeMs: 0 } +const DEV_ROOT = '/dev' +const NULL_PATH = '/dev/null' + +/** Build one launcher-owned terminal result. */ +function launcherExit(exitCode: number, stdout = '', stderr = ''): VirtualExecutableExit { + return { kind: 'exit', exitCode, stdout, stderr } +} + +/** Convert a parser or grant failure into the native launcher's fatal dialect. */ +function launcherFailure(error: unknown): VirtualExecutableExit { + const detail = error instanceof LandlockLauncherError ? error.message : String(error) + return launcherExit(125, '', `landlock-run: ${detail}\n`) +} + +/** + * Validate grant roots and create one process-local filesystem guard. + * @param base - Host-side VFS adapter all permitted calls delegate to. + * @param invocation - Parsed confined-run request. + * @param cwd - Launcher's working directory for relative grant paths. + * @returns A filesystem enforcing only this invocation's grants. + */ +export async function landlockFileSystem( + base: ShellFileSystem, + invocation: Extract, + cwd: string, +): Promise { + const normalizeGrant = async (path: string): Promise => { + if (path === '') throw new LandlockLauncherError('cannot open rule path: : No such file or directory') + const target = vfsPath(path, cwd) + if (target !== DEV_ROOT && target !== NULL_PATH && await base.stat(target) === undefined) { + throw new LandlockLauncherError(`cannot open rule path: ${path}: No such file or directory`) + } + return target + } + const readOnly = await Promise.all(invocation.readOnly.map(normalizeGrant)) + const readWrite = await Promise.all(invocation.readWrite.map(normalizeGrant)) + const readable = [...readOnly, ...readWrite] + + const readPath = (path: string, syscall: string): string => { + const target = vfsPath(path, cwd) + if (!readable.some(root => contains(root, target))) deny(syscall, path) + return target + } + const writePath = (path: string, syscall: string): string => { + const target = vfsPath(path, cwd) + if (!readWrite.some(root => contains(root, target))) deny(syscall, path) + return target + } + + return { + stat: async (path: string): Promise => { + const target = readPath(path, 'stat') + if (target === NULL_PATH) return NULL_STATS + if (target === DEV_ROOT && !await base.stat(target)) return { directory: true, size: 0, mtimeMs: 0 } + return await base.stat(target) + }, + list: async (path: string): Promise => { + const target = readPath(path, 'scandir') + if (target === DEV_ROOT) return [{ name: 'null', directory: false }] + if (target === NULL_PATH) throw filesystemError('ENOTDIR', 'scandir', path) + return await base.list(target) + }, + readText: async (path: string): Promise => { + const target = readPath(path, 'open') + return target === NULL_PATH ? '' : await base.readText(target) + }, + writeText: async (path: string, text: string, append = false): Promise => { + const target = writePath(path, 'open') + if (target !== NULL_PATH) await base.writeText(target, text, append) + }, + mkdir: async (path: string, recursive: boolean): Promise => { + const target = writePath(path, 'mkdir') + if (target === NULL_PATH) throw filesystemError('EEXIST', 'mkdir', path) + await base.mkdir(target, recursive) + }, + remove: async (path: string, options: { recursive: boolean; force: boolean }): Promise => { + const target = writePath(path, 'rm') + if (target === NULL_PATH) deny('rm', path) + await base.remove(target, options) + }, + rename: async (from: string, to: string): Promise => { + const source = writePath(from, 'rename') + const destination = writePath(to, 'rename') + if (source === NULL_PATH || destination === NULL_PATH) deny('rename', source === NULL_PATH ? from : to) + await base.rename(source, destination) + }, + } +} + +/** Virtual executable implementing the native launcher's CLI over VFS grants. */ +export const LANDLOCK_EXECUTABLE: VirtualExecutable = { + name: 'landlock-run', + async prepare(args, context) { + try { + const invocation = parseLandlockArguments(args) + if (invocation.kind === 'probe') return launcherExit(0, 'landlock: fully enforced\n') + return { + kind: 'delegate', + argv: invocation.argv, + filesystem: await landlockFileSystem(context.filesystem, invocation, context.cwd), + missingExecutable: launcherExit(125, '', 'landlock-run: exec failed: No such file or directory\n'), + } + } catch (error) { + return launcherFailure(error) + } + }, + runSync(args) { + try { + const invocation = parseLandlockArguments(args) + return invocation.kind === 'probe' + ? launcherExit(0, 'landlock: fully enforced\n') + : { kind: 'asynchronous' } + } catch (error) { + return launcherFailure(error) + } + }, +} diff --git a/packages/experimental/webworker-runtime/src/shell/process/virtual-executables.ts b/packages/experimental/webworker-runtime/src/shell/process/virtual-executables.ts new file mode 100644 index 0000000000..6f04cf1865 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/shell/process/virtual-executables.ts @@ -0,0 +1,61 @@ +/** Virtual executable registry used by the Worker process launcher. */ +import { basename } from '../../module-system/posix-path.ts' +import type { ShellFileSystem } from '../types.ts' +import { LANDLOCK_EXECUTABLE } from './landlock.ts' + +/** Completed virtual executable invocation. */ +export interface VirtualExecutableExit { + readonly kind: 'exit' + readonly exitCode: number + readonly stdout: string + readonly stderr: string +} + +/** Invocation delegated to the normal Worker command runner after preparation. */ +export interface VirtualExecutableDelegate { + readonly kind: 'delegate' + readonly argv: readonly string[] + readonly filesystem: ShellFileSystem + readonly missingExecutable: VirtualExecutableExit +} + +/** Result of preparing an asynchronous virtual executable invocation. */ +export type VirtualExecutablePreparation = VirtualExecutableExit | VirtualExecutableDelegate + +/** Result available to the synchronous child-process face. */ +export type VirtualExecutableSyncResult = VirtualExecutableExit | { readonly kind: 'asynchronous' } + +/** One executable implemented by the Worker instead of an operating-system binary. */ +export interface VirtualExecutable { + /** Platform executable name, independent of package-manager installation path. */ + readonly name: string + /** + * Prepare an invocation or complete it without entering the command runner. + * @param args - Arguments after the executable path. + * @param context - Working directory and ambient Worker filesystem. + * @returns The completed result or delegated command and filesystem. + */ + prepare( + args: readonly string[], + context: { readonly cwd: string; readonly filesystem: ShellFileSystem }, + ): Promise + /** + * Handle the subset that can complete synchronously. + * @param args - Arguments after the executable path. + * @returns A completed result or the asynchronous marker. + */ + runSync(args: readonly string[]): VirtualExecutableSyncResult +} + +const EXECUTABLES: ReadonlyMap = new Map([ + [LANDLOCK_EXECUTABLE.name, LANDLOCK_EXECUTABLE], +]) + +/** + * Resolve a Worker platform executable by logical name. + * @param path - Bare name or executable path passed to `spawn`. + * @returns Its implementation, or undefined for the normal command table. + */ +export function virtualExecutable(path: string): VirtualExecutable | undefined { + return EXECUTABLES.get(basename(path)) +} diff --git a/packages/experimental/webworker-runtime/src/storage/active.ts b/packages/experimental/webworker-runtime/src/storage/active.ts index ce54337b21..74cfc63b41 100644 --- a/packages/experimental/webworker-runtime/src/storage/active.ts +++ b/packages/experimental/webworker-runtime/src/storage/active.ts @@ -4,15 +4,15 @@ * which backend the worker entry mounted. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active */ -import type { MemoryVfs } from './memory.ts' +import type { Vfs } from './types.ts' -let active: MemoryVfs | undefined +let active: Vfs | undefined /** * Publish the filesystem the `node:fs` proxy reads. * @param vfs - Filesystem mounted by the worker entry. */ -export function setActiveVfs(vfs: MemoryVfs): void { +export function setActiveVfs(vfs: Vfs): void { active = vfs } @@ -20,7 +20,7 @@ export function setActiveVfs(vfs: MemoryVfs): void { * Read the mounted filesystem. * @returns The active filesystem. */ -export function requireActiveVfs(): MemoryVfs { +export function requireActiveVfs(): Vfs { if (active === undefined) { throw new Error('webworker vfs: no filesystem is mounted; the worker entry must call setActiveVfs before any node:fs access') } diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index f4a544c001..96a63f859f 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -1,14 +1,14 @@ /** * In-memory filesystem behind the worker's `node:fs` proxy. Contents come from - * the build-time image (see {@link loadVfsImage}); writes stay in memory and - * vanish with the worker. + * the build-time image (see {@link loadVfsImage}); this remains the synchronous + * authority when an asynchronous durable sink mirrors selected subtrees. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory */ import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-path.ts' import { parseTar } from './tar.ts' import type { - VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsReadOptions, VfsStatOptions, - VfsStats, VfsWriteOptions, + Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, + VfsMutationListener, VfsMutationSink, VfsReadOptions, VfsSeedOptions, VfsStatOptions, VfsStats, VfsWriteOptions, } from './types.ts' const decoder = new TextDecoder() @@ -46,10 +46,14 @@ function encodingOf(options: VfsReadOptions): VfsEncoding | undefined { // stored value — the round-trip consumers like dsh-credentials-local's // owner-only check rely on. The bits are never enforced: a single-owner // filesystem reads and writes as its owner regardless, like root. -function statsOf(size: number, mtimeMs: number, directory: boolean, mode: number): VfsStats { +function statsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, mode: number): VfsStats { return { size, + ino: Number(ino), mtimeMs, + ctimeMs: mtimeMs, + atimeMs: mtimeMs, + birthtimeMs: mtimeMs, mtime: new Date(mtimeMs), mode: (directory ? 0o040000 : 0o100000) | (mode & 0o777), isFile: () => !directory, @@ -107,16 +111,26 @@ function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: b } } +/** Construction inputs for {@link MemoryVfs}. */ +export interface MemoryVfsOptions { + /** Durable write-behind observer; absent leaves the filesystem ephemeral. */ + readonly sink?: VfsMutationSink +} + /** * Filesystem held in two maps: one for file bytes, one for directories. * Every path is normalized to an absolute POSIX path without a trailing * separator, so callers may pass either form. */ -export class MemoryVfs { +export class MemoryVfs implements Vfs { private readonly files = new Map() private readonly directories = new Set([SEP]) /** Directory permission bits; absence means {@link DEFAULT_DIRECTORY_MODE}. */ private readonly directoryModes = new Map() + /** Directory mtimes advance when their immediate entry set changes. */ + private readonly directoryMtimes = new Map() + private readonly mutationListeners = new Set() + private readonly sink: VfsMutationSink | undefined private temporaries = 0 // Identity per path, assigned on first stat and dropped when the path goes: // the filesystem service builds its version token from `ino` plus the @@ -124,6 +138,47 @@ export class MemoryVfs { private readonly identities = new Map() private lastIdentity = 0n + /** + * Build the synchronous filesystem authority. + * @param options - Optional durable write-behind sink. + */ + constructor(options: MemoryVfsOptions = {}) { + this.sink = options.sink + } + + /** + * Settle the durable sink without changing in-memory success. + * @returns A promise that resolves when all recorded mutations are stored. + */ + async flush(): Promise { + await this.sink?.flush() + } + + /** + * Observe committed runtime mutations. Image seeding is deliberately silent. + * @param listener - Consumer called after each successful mutation. + * @returns A disposer that prevents future calls. + */ + subscribe(listener: VfsMutationListener): () => void { + this.mutationListeners.add(listener) + return () => { this.mutationListeners.delete(listener) } + } + + /** Publish after state changes; one faulty observer cannot roll back a write. */ + private publish(mutation: VfsMutation): void { + const observers: VfsMutationListener[] = [ + ...(this.sink === undefined ? [] : [(change: VfsMutation): void => { this.sink?.record(change) }]), + ...this.mutationListeners, + ] + for (const listener of observers) { + try { + listener(mutation) + } catch (error) { + console.error('webworker vfs: mutation observer failed', error) + } + } + } + /** Promise face mirroring `node:fs/promises` for the methods the roster uses. */ readonly promises = { readFile: async (path: string, options?: VfsReadOptions): Promise => this.readFileSync(path, options), @@ -198,11 +253,12 @@ export class MemoryVfs { const [size, mtimeMs, directory, mode] = node !== undefined ? [node.bytes.length, node.mtimeMs, false, node.mode] as const : this.directories.has(target) - ? [0, 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const + ? [0, this.directoryMtimes.get(target) ?? 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const : fail('ENOENT', 'stat', target) + const identity = this.identityOf(target) return options?.bigint === true - ? bigIntStatsOf(size, mtimeMs, directory, this.identityOf(target), mode) - : statsOf(size, mtimeMs, directory, mode) + ? bigIntStatsOf(size, mtimeMs, directory, identity, mode) + : statsOf(size, mtimeMs, directory, identity, mode) } /** @returns Stats in the plain shape, for internal callers that read `size`/`mtimeMs`. */ @@ -244,6 +300,13 @@ export class MemoryVfs { return previous === undefined ? now : Math.max(now, previous + 1) } + /** Advance a directory's mtime after its immediate children change. */ + private touchDirectory(target: string): void { + const previous = this.directoryMtimes.get(target) + const now = Date.now() + this.directoryMtimes.set(target, previous === undefined ? now : Math.max(now, previous + 1)) + } + /** * List a directory. * @param path - Directory path. @@ -312,7 +375,11 @@ export class MemoryVfs { this.mkdirSync(parent, options) } this.directories.add(target) - if (options?.mode !== undefined) this.directoryModes.set(target, options.mode & 0o777) + this.touchDirectory(target) + this.touchDirectory(parent) + const mode = (options?.mode ?? DEFAULT_DIRECTORY_MODE) & 0o777 + if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode) + this.publish({ kind: 'mkdir', path: target, mode }) return target } @@ -331,8 +398,14 @@ export class MemoryVfs { if (flag.startsWith('a')) { this.appendFileSync(target, data); return } // POSIX open(O_CREAT): the mode applies at creation only; a rewrite keeps // the entry's bits. - const mode = this.files.get(target)?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE) - this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target), mode }) + const previous = this.files.get(target) + const mode = previous?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE) + const bytes = typeof data === 'string' ? encoder.encode(data) : data + this.files.set(target, { bytes, mtimeMs: this.touch(target), mode }) + if (previous === undefined) this.touchDirectory(dirname(target)) + this.publish({ + kind: 'write', path: target, bytes, mode, entryChanged: previous === undefined, + }) } /** @@ -405,7 +478,9 @@ export class MemoryVfs { truncate: async (length = 0): Promise => { const node = this.files.get(target) if (node === undefined) fail('ENOENT', 'ftruncate', target) - this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target), mode: node.mode }) + const bytes = node.bytes.slice(0, length) + this.files.set(target, { bytes, mtimeMs: this.touch(target), mode: node.mode }) + this.publish({ kind: 'write', path: target, bytes, mode: node.mode, entryChanged: false }) }, ...this.handleTail(target), } @@ -414,17 +489,17 @@ export class MemoryVfs { /** * The handle members that do not depend on how the file was opened. * - * `sync`/`datasync` have nothing to flush — the bytes are already the stored - * ones — and `close` releases nothing, so both directory and file handles - * share this tail. + * `sync`/`datasync` settle an attached durable sink; an ephemeral filesystem + * resolves immediately. `close` releases nothing, so both directory and file + * handles share this tail. * @param target - Normalized path the handle was opened on. * @returns Metadata plus the no-op durability and release calls. */ private handleTail(target: string): Pick { return { stat: async (): Promise => this.plainStats(target), - sync: async (): Promise => {}, - datasync: async (): Promise => {}, + sync: async (): Promise => { await this.flush() }, + datasync: async (): Promise => { await this.flush() }, close: async (): Promise => {}, } } @@ -443,6 +518,10 @@ export class MemoryVfs { merged.set(existing.bytes) merged.set(addition, existing.bytes.length) this.files.set(target, { bytes: merged, mtimeMs: this.touch(target), mode: existing.mode }) + this.publish({ + kind: 'write', path: target, bytes: merged, mode: existing.mode, + entryChanged: false, appendedFrom: existing.bytes.length, + }) } /** @@ -460,15 +539,23 @@ export class MemoryVfs { this.files.set(destination, node) this.forgetIdentity(source) this.forgetIdentity(destination) + this.touchDirectory(dirname(source)) + this.touchDirectory(dirname(destination)) + this.publish({ kind: 'remove', path: source }) + this.publish({ kind: 'write', path: destination, bytes: node.bytes, mode: node.mode, entryChanged: true }) return } if (!this.directories.has(source)) fail('ENOENT', 'rename', source) const prefix = `${source}${SEP}` + const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = [] for (const [candidate, value] of [...this.files]) { if (!candidate.startsWith(prefix)) continue this.files.delete(candidate) - this.files.set(join(destination, candidate.slice(prefix.length)), value) + const target = join(destination, candidate.slice(prefix.length)) + this.files.set(target, value) + movedFiles.push({ path: target, bytes: value.bytes, mode: value.mode }) } + const movedDirectories: Array<{ path: string; mode: number }> = [] for (const candidate of [...this.directories]) { if (!candidate.startsWith(prefix) && candidate !== source) continue const moved = candidate === source ? destination : join(destination, candidate.slice(prefix.length)) @@ -477,9 +564,24 @@ export class MemoryVfs { const bits = this.directoryModes.get(candidate) this.directoryModes.delete(candidate) if (bits !== undefined) this.directoryModes.set(moved, bits) + movedDirectories.push({ path: moved, mode: bits ?? DEFAULT_DIRECTORY_MODE }) + const mtime = this.directoryMtimes.get(candidate) + this.directoryMtimes.delete(candidate) + if (mtime !== undefined) this.directoryMtimes.set(moved, mtime) } this.forgetIdentity(source) this.forgetIdentity(destination) + this.touchDirectory(dirname(source)) + this.touchDirectory(dirname(destination)) + this.publish({ kind: 'remove', path: source }) + for (const directory of movedDirectories) { + this.publish({ kind: 'mkdir', path: directory.path, mode: directory.mode }) + } + for (const entry of movedFiles) { + this.publish({ + kind: 'write', path: entry.path, bytes: entry.bytes, mode: entry.mode, entryChanged: true, + }) + } } /** @@ -499,6 +601,8 @@ export class MemoryVfs { if (this.files.has(target) || this.directories.has(target)) fail('EEXIST', 'link', target) if (!this.directories.has(dirname(target))) fail('ENOENT', 'link', target) this.files.set(target, node) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'write', path: target, bytes: node.bytes, mode: node.mode, entryChanged: true }) } /** @@ -510,7 +614,9 @@ export class MemoryVfs { const target = this.key(path) const node = this.files.get(target) if (node === undefined) fail('ENOENT', 'truncate', target) - this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target), mode: node.mode }) + const bytes = node.bytes.slice(0, length) + this.files.set(target, { bytes, mtimeMs: this.touch(target), mode: node.mode }) + this.publish({ kind: 'write', path: target, bytes, mode: node.mode, entryChanged: false }) } /** @@ -523,10 +629,13 @@ export class MemoryVfs { const node = this.files.get(target) if (node !== undefined) { node.mode = mode & 0o777 + this.publish({ kind: 'chmod', path: target, mode: node.mode }) return } if (this.directories.has(target)) { - this.directoryModes.set(target, mode & 0o777) + const bits = mode & 0o777 + this.directoryModes.set(target, bits) + this.publish({ kind: 'chmod', path: target, mode: bits }) return } fail('ENOENT', 'chmod', target) @@ -540,6 +649,8 @@ export class MemoryVfs { const target = this.key(path) if (!this.files.delete(target)) fail('ENOENT', 'unlink', target) this.forgetIdentity(target) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'remove', path: target }) } /** @@ -551,6 +662,8 @@ export class MemoryVfs { const target = this.key(path) if (this.files.delete(target)) { this.forgetIdentity(target) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'remove', path: target }) return } if (this.directories.has(target)) { @@ -561,10 +674,14 @@ export class MemoryVfs { if (!candidate.startsWith(prefix)) continue this.directories.delete(candidate) this.directoryModes.delete(candidate) + this.directoryMtimes.delete(candidate) } this.directories.delete(target) this.directoryModes.delete(target) + this.directoryMtimes.delete(target) this.forgetIdentity(target) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'remove', path: target }) return } if (options?.force !== true) fail('ENOENT', 'rm', target) @@ -586,23 +703,36 @@ export class MemoryVfs { * Seed a file and its parent directories, for image loading and tests. * @param path - File path. * @param data - Text or bytes. - * @param mode - Permission bits recorded for the entry. + * @param options - Permission bits and modification time supplied by the image or durable store. */ - seed(path: string, data: string | Uint8Array, mode = DEFAULT_FILE_MODE): void { + seed(path: string, data: string | Uint8Array, options: VfsSeedOptions = {}): void { const target = this.key(path) - this.mkdirSync(dirname(target), { recursive: true }) - this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target), mode: mode & 0o777 }) + this.seedDirectory(dirname(target)) + this.files.set(target, { + bytes: typeof data === 'string' ? encoder.encode(data) : data, + mtimeMs: options.mtimeMs ?? this.touch(target), + mode: (options.mode ?? DEFAULT_FILE_MODE) & 0o777, + }) + this.touchDirectory(dirname(target)) } /** * Create a directory and its parents. * @param path - Directory path. - * @param mode - Permission bits recorded for the directory itself. + * @param options - Permission bits and modification time supplied by the image or durable store. */ - seedDirectory(path: string, mode = DEFAULT_DIRECTORY_MODE): void { + seedDirectory(path: string, options: VfsSeedOptions = {}): void { const target = this.key(path) - this.mkdirSync(target, { recursive: true }) - if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode & 0o777) + if (!this.directories.has(target)) { + const parent = dirname(target) + if (parent !== target) this.seedDirectory(parent) + if (this.files.has(target)) fail('EEXIST', 'mkdir', target) + this.directories.add(target) + this.directoryMtimes.set(target, options.mtimeMs ?? Date.now()) + this.touchDirectory(parent) + } + if (options.mode !== undefined) this.directoryModes.set(target, options.mode & 0o777) + if (options.mtimeMs !== undefined) this.directoryMtimes.set(target, options.mtimeMs) } /** @@ -636,10 +766,10 @@ export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryV } const target = join(root, relativeName) if (entry.directory) { - vfs.seedDirectory(target, entry.mode) + vfs.seedDirectory(target, { mode: entry.mode }) continue } - vfs.seed(target, entry.bytes, entry.mode) + vfs.seed(target, entry.bytes, { mode: entry.mode }) } return vfs } diff --git a/packages/experimental/webworker-runtime/src/storage/types.ts b/packages/experimental/webworker-runtime/src/storage/types.ts index 9208761407..e879d5f11f 100644 --- a/packages/experimental/webworker-runtime/src/storage/types.ts +++ b/packages/experimental/webworker-runtime/src/storage/types.ts @@ -22,7 +22,12 @@ export interface VfsError extends Error { /** Subset of `fs.Stats` the roster reads. */ export interface VfsStats { readonly size: number + /** Stable identity while an entry exists; recreation receives another value. */ + readonly ino: number readonly mtimeMs: number + readonly ctimeMs: number + readonly atimeMs: number + readonly birthtimeMs: number readonly mtime: Date readonly mode: number isFile(): boolean @@ -85,6 +90,12 @@ export interface VfsWriteOptions { readonly flag?: string } +/** Explicit metadata for image or durable-store hydration. */ +export interface VfsSeedOptions { + readonly mode?: number + readonly mtimeMs?: number +} + /** Directory entry as `readdir` with `withFileTypes` reports it. */ export interface VfsDirent { readonly name: string @@ -113,3 +124,90 @@ export interface VfsFileHandle { datasync(): Promise close(): Promise } + +/** + * One completed change to the authoritative in-memory filesystem. + * + * A durable mirror receives the post-write bytes, virtual permission bits, and optional append offset; + * live watchers use `entryChanged` to distinguish directory-entry replacement + * from content writes. Rename is represented as source removal plus complete + * destination mkdir/write records, so a sink never receives a path without the + * state needed to materialize it. + */ +export type VfsMutation = + | { + readonly kind: 'write' + readonly path: string + readonly bytes: Uint8Array + readonly mode: number + readonly entryChanged: boolean + readonly appendedFrom?: number + } + | { readonly kind: 'mkdir'; readonly path: string; readonly mode: number } + | { readonly kind: 'remove'; readonly path: string } + | { readonly kind: 'chmod'; readonly path: string; readonly mode: number } + +/** Receives one committed VFS mutation. */ +export type VfsMutationListener = (mutation: VfsMutation) => void + +/** Durable observer attached to the synchronous VFS. */ +export interface VfsMutationSink { + /** + * Record one completed mutation without delaying its caller. + * @param mutation - Post-commit state to mirror. + */ + record(mutation: VfsMutation): void + /** + * Settle all previously recorded mutations. + * Implementations report persistence failures and stop mirroring rather than + * rejecting, because the in-memory mutation has already committed. + * @returns A promise that resolves when the sink has no pending work. + */ + flush(): Promise +} + +/** Synchronous filesystem used by the worker's Node compatibility modules. */ +export interface Vfs { + readonly promises: { + readFile(path: string, options?: VfsReadOptions): Promise + writeFile(path: string, data: string | Uint8Array, options?: VfsWriteOptions): Promise + appendFile(path: string, data: string | Uint8Array): Promise + mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise + readdir(path: string, options?: { withFileTypes?: boolean }): Promise + stat(path: string, options?: VfsStatOptions): Promise + lstat(path: string, options?: VfsStatOptions): Promise + realpath(path: string): Promise + rename(from: string, to: string): Promise + unlink(path: string): Promise + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise + mkdtemp(prefix: string): Promise + link(existing: string, next: string): Promise + truncate(path: string, length?: number): Promise + chmod(path: string, mode: number): Promise + opendir(path: string): Promise + open(path: string, flags?: string, mode?: number): Promise + access(path: string): Promise + } + readFileSync(path: string, options?: VfsReadOptions): string | Uint8Array + existsSync(path: string): boolean + statSync(path: string, options?: VfsStatOptions): VfsStats | VfsBigIntStats + readdirSync(path: string, options?: { withFileTypes?: boolean }): string[] & VfsDirent[] + realpathSync(path: string): string + mkdirSync(path: string, options?: { recursive?: boolean; mode?: number }): string | undefined + writeFileSync(path: string, data: string | Uint8Array, options?: VfsWriteOptions): void + appendFileSync(path: string, data: string | Uint8Array): void + renameSync(from: string, to: string): void + linkSync(existing: string, next: string): void + truncateSync(path: string, length?: number): void + chmodSync(path: string, mode: number): void + unlinkSync(path: string): void + rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void + mkdtempSync(prefix: string): string + seed(path: string, data: string | Uint8Array, options?: VfsSeedOptions): void + seedDirectory(path: string, options?: VfsSeedOptions): void + usage(): { files: number; directories: number; bytes: number } + /** Register one observer and return its synchronous disposer. */ + subscribe(listener: VfsMutationListener): () => void + /** Settle the attached durable mutation sink, if any. */ + flush(): Promise +} diff --git a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts index 619f95f3ce..6a5826a19a 100644 --- a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts @@ -16,13 +16,22 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts' import { spawn, spawnSync } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process.ts' +import { + LAUNCHER_FAILURE_EXIT, grantArgs, launcherPath, probe, +} from '@deepseek-ai/node-addon-landlock-run' import { processAlive, signalProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/process-table.ts' +import { hostFileSystem } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/fs-access.ts' +import { + LANDLOCK_EXECUTABLE, landlockFileSystem, parseLandlockArguments, +} from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/landlock.ts' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' vi.mock('node:child_process', async () => await import('@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process.ts')) const WORKSPACE = '/dsh/workspace' +const HOME = '/dsh/home' +const TMP = '/dsh/tmp' let vfs: MemoryVfs @@ -30,6 +39,8 @@ beforeEach(() => { vfs = new MemoryVfs() setActiveVfs(vfs) vfs.mkdirSync(WORKSPACE, { recursive: true }) + vfs.mkdirSync(HOME, { recursive: true }) + vfs.mkdirSync(TMP, { recursive: true }) vi.spyOn(process, 'kill').mockImplementation((pid: number, signal?: string | number): true => { if (signal === 0) { if (processAlive(pid)) return true @@ -91,6 +102,180 @@ it('refuses a command name that is not a string, as Node does', () => { it('reports that a synchronous run cannot happen, without throwing at the probe', () => { expect(spawnSync('bwrap').error?.code).toBe('ENOENT') expect(spawnSync('echo').error?.message).toContain('commands run asynchronously') + expect(spawnSync(launcherPath(), ['--probe'])).toMatchObject({ + status: 0, + stdout: Buffer.from('landlock: fully enforced\n'), + }) + expect(spawnSync(launcherPath(), ['--ro', '/', '--', 'echo', 'x']).error?.message) + .toContain('commands run asynchronously') + expect(spawnSync(launcherPath(), ['--probe', '--'])).toMatchObject({ + status: LAUNCHER_FAILURE_EXIT, + stderr: expect.any(Buffer), + }) +}) + +it('keeps the native Landlock package API and CLI failure contract', async () => { + expect(probe()).toBe('full') + expect(probe('/not-the-worker-launcher')).toBe('unusable') + expect(probe('/another-package-layout/bin/landlock-run')).toBe('full') + expect(launcherPath(() => '/ignored/package.json')).toBe('/ignored/bin/landlock-run') + expect(LAUNCHER_FAILURE_EXIT).toBe(125) + expect(await collect(spawn(launcherPath(), ['--probe']))).toEqual({ + stdout: 'landlock: fully enforced\n', stderr: '', code: 0, + }) + const malformed = spawn(launcherPath(), ['--rw'], { cwd: WORKSPACE }) + expect(await collect(malformed)).toEqual({ + stdout: '', + stderr: 'landlock-run: usage error: --rw requires a path\n', + code: 125, + }) + const missingGrant = spawn(launcherPath(), ['--rw', '/dsh/missing', '--', 'touch', `${WORKSPACE}/never`], { cwd: WORKSPACE }) + expect(await collect(missingGrant)).toEqual({ + stdout: '', + stderr: 'landlock-run: cannot open rule path: /dsh/missing: No such file or directory\n', + code: 125, + }) + expect(vfs.existsSync(`${WORKSPACE}/never`)).toBe(false) + const missingCommand = spawn(launcherPath(), ['--ro', '/', '--', 'not-a-program'], { cwd: WORKSPACE }) + expect(await collect(missingCommand)).toEqual({ + stdout: '', + stderr: 'landlock-run: exec failed: No such file or directory\n', + code: 125, + }) +}) + +it('enforces every ShellFileSystem operation and virtual device edge', async () => { + vfs.writeFileSync(`${HOME}/private.txt`, 'private\n') + const invocation = parseLandlockArguments([ + ...grantArgs({ readOnly: ['/dev'], readWrite: [WORKSPACE, '/dev/null'] }), '--', 'true', + ]) + if (invocation.kind !== 'run') throw new Error('expected a confined run invocation') + const guarded = await landlockFileSystem(hostFileSystem(), invocation, WORKSPACE) + + expect(await guarded.stat('/dev/null')).toEqual({ directory: false, size: 0, mtimeMs: 0 }) + expect(await guarded.stat('/dev')).toEqual({ directory: true, size: 0, mtimeMs: 0 }) + expect(await guarded.list('/dev')).toEqual([{ name: 'null', directory: false }]) + await expect(guarded.list('/dev/null')).rejects.toMatchObject({ code: 'ENOTDIR' }) + expect(await guarded.readText('/dev/null')).toBe('') + await guarded.writeText('/dev/null', 'discarded') + await expect(guarded.mkdir('/dev/null', false)).rejects.toMatchObject({ code: 'EEXIST' }) + await expect(guarded.remove('/dev/null', { recursive: false, force: false })).rejects.toMatchObject({ code: 'EACCES' }) + await expect(guarded.rename('/dev/null', `${WORKSPACE}/null`)).rejects.toMatchObject({ code: 'EACCES' }) + await expect(guarded.readText(`${HOME}/private.txt`)).rejects.toMatchObject({ code: 'EACCES' }) + + await guarded.mkdir('created', false) + await guarded.writeText('created/file', 'one') + await guarded.writeText('created/file', ' two', true) + expect(await guarded.readText(`${WORKSPACE}/created/file`)).toBe('one two') + expect(await guarded.list(`${WORKSPACE}/created`)).toEqual([{ name: 'file', directory: false }]) + await guarded.rename('created/file', 'created/moved') + await expect(guarded.rename('created/moved', '/dev/null')).rejects.toMatchObject({ code: 'EACCES' }) + await guarded.remove('created', { recursive: true, force: false }) + expect(vfs.existsSync(`${WORKSPACE}/created`)).toBe(false) +}) + +it('turns an unexpected virtual-launcher preparation failure into exit 125', async () => { + const base = hostFileSystem() + const result = await LANDLOCK_EXECUTABLE.prepare( + ['--ro', '/', '--', 'true'], + { + cwd: WORKSPACE, + filesystem: { ...base, stat: () => Promise.reject(new Error('storage unavailable')) }, + }, + ) + expect(result).toEqual({ + kind: 'exit', exitCode: 125, stdout: '', stderr: 'landlock-run: Error: storage unavailable\n', + }) +}) + +it.each([ + { args: [], message: 'missing `-- ...` command' }, + { args: ['--unknown', '--', 'true'], message: 'unknown argument: --unknown' }, + { args: ['--probe', '--'], message: '--probe takes no other arguments' }, + { args: ['--'], message: 'missing `-- ...` command' }, + { args: ['--rw', '', '--', 'true'], message: 'cannot open rule path' }, +])('rejects malformed Landlock argv before execution: $message', async ({ args, message }) => { + const child = spawn(launcherPath(), args, { cwd: WORKSPACE }) + const result = await collect(child) + expect(result.code).toBe(LAUNCHER_FAILURE_EXIT) + expect(result.stderr).toContain(message) +}) + +it('enforces read-only and workspace-write grants over the VFS', async () => { + vfs.writeFileSync(`${HOME}/readable.txt`, 'visible\n') + const readOnly = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }), + '--', 'bash', '-c', `cat ${HOME}/readable.txt; echo discarded > /dev/null; echo denied > ${WORKSPACE}/denied.txt`, + ], { cwd: WORKSPACE }) + const strict = await collect(readOnly) + expect(strict.code).toBe(1) + expect(strict.stdout).toBe('visible\n') + expect(strict.stderr.toLowerCase()).toContain('permission denied') + expect(vfs.existsSync(`${WORKSPACE}/denied.txt`)).toBe(false) + + const workspaceWrite = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null', '/tmp', WORKSPACE] }), + '--', 'bash', '-c', `echo workspace > ${WORKSPACE}/allowed.txt; echo temporary > /tmp/temp.txt; cat /tmp/temp.txt`, + ], { cwd: WORKSPACE }) + expect(await collect(workspaceWrite)).toEqual({ stdout: 'temporary\n', stderr: '', code: 0 }) + expect(vfs.readFileSync(`${WORKSPACE}/allowed.txt`, 'utf8')).toBe('workspace\n') + expect(vfs.readFileSync(`${TMP}/temp.txt`, 'utf8')).toBe('temporary\n') + expect(vfs.existsSync('/dev/null')).toBe(false) +}) + +it('normalizes relative grants and denies sibling-prefix escapes and unreadable paths', async () => { + vfs.mkdirSync(`${WORKSPACE}/nested`) + vfs.mkdirSync(`${WORKSPACE}-other`) + vfs.writeFileSync(`${HOME}/private.txt`, 'private\n') + const child = spawn(launcherPath(), [ + ...grantArgs({ readOnly: [WORKSPACE], readWrite: ['.'] }), + '--', 'bash', '-c', `echo kept > nested/relative.txt; echo escaped > ${WORKSPACE}-other/escape.txt; cat ${HOME}/private.txt`, + ], { cwd: WORKSPACE }) + const result = await collect(child) + expect(result.code).toBe(1) + expect(result.stderr.toLowerCase()).toContain('permission denied') + expect(vfs.readFileSync(`${WORKSPACE}/nested/relative.txt`, 'utf8')).toBe('kept\n') + expect(vfs.existsSync(`${WORKSPACE}-other/escape.txt`)).toBe(false) + expect(result.stdout).not.toContain('private') +}) + +it('presents the virtual device directory without storing it in the VFS', async () => { + const child = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }), + '--', 'bash', '-c', 'ls /dev; cat /dev/null', + ], { cwd: WORKSPACE }) + expect(await collect(child)).toEqual({ stdout: 'null\n', stderr: '', code: 0 }) + expect(vfs.existsSync('/dev')).toBe(false) +}) + +it('requires both rename paths to be writable', async () => { + vfs.writeFileSync(`${WORKSPACE}/source.txt`, 'kept\n') + const child = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: [WORKSPACE] }), + '--', 'mv', `${WORKSPACE}/source.txt`, `${HOME}/moved.txt`, + ], { cwd: WORKSPACE }) + const result = await collect(child) + expect(result.code).toBe(1) + expect(result.stderr.toLowerCase()).toContain('permission denied') + expect(vfs.readFileSync(`${WORKSPACE}/source.txt`, 'utf8')).toBe('kept\n') + expect(vfs.existsSync(`${HOME}/moved.txt`)).toBe(false) +}) + +it('keeps concurrent Landlock grants process-local', async () => { + const strict = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }), + '--', 'bash', '-c', `sleep 0.02; echo denied > ${WORKSPACE}/strict.txt`, + ], { cwd: WORKSPACE }) + const writable = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null', WORKSPACE] }), + '--', 'bash', '-c', `echo allowed > ${WORKSPACE}/writable.txt`, + ], { cwd: WORKSPACE }) + const [strictResult, writableResult] = await Promise.all([collect(strict), collect(writable)]) + expect(strictResult.code).toBe(1) + expect(strictResult.stderr.toLowerCase()).toContain('permission denied') + expect(writableResult).toEqual({ stdout: '', stderr: '', code: 0 }) + expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false) + expect(vfs.readFileSync(`${WORKSPACE}/writable.txt`, 'utf8')).toBe('allowed\n') }) it('carries a command through the real local subprocess service', async () => { diff --git a/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts b/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts new file mode 100644 index 0000000000..82066b810a --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts @@ -0,0 +1,210 @@ +/** Upstream Chokidar running unchanged through the shipped Worker module loader. */ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { lowerModuleSource } from '../../src/compile/transform.ts' +import { WorkerModuleLoader } from '../../src/module-system/module-loader.ts' +import { createNodeBuiltins } from '../../src/node/builtins.ts' +import { MemoryVfs } from '../../src/storage/memory.ts' +import { setActiveVfs } from '../../src/storage/active.ts' + +const ROOT = '/dsh/workspace/skills' +let vfs: MemoryVfs +let chokidar: typeof import('chokidar') +const openWatchers: import('chokidar').FSWatcher[] = [] + +interface ChokidarFixture { + readonly label: string + readonly consumerManifest: string + readonly chokidarFiles: readonly string[] + readonly readdirpFiles: readonly string[] +} + +const CHOKIDAR_FIXTURES: readonly ChokidarFixture[] = [ + { + label: 'Chokidar 4 from settings and credentials', + consumerManifest: 'packages/settings/settings-file/package.json', + chokidarFiles: ['package.json', 'esm/package.json', 'esm/index.js', 'esm/handler.js'], + readdirpFiles: ['package.json', 'esm/package.json', 'esm/index.js'], + }, + { + label: 'Chokidar 5 from skill-filesystem', + consumerManifest: 'packages/skill/skill-filesystem/package.json', + chokidarFiles: ['package.json', 'index.js', 'handler.js'], + readdirpFiles: ['package.json', 'index.js'], + }, +] + +/** Copy one installed JavaScript package into the VFS exactly as the packer does. */ +function packageRoot(name: string, entry: string): string { + for (let directory = dirname(entry);;) { + const manifest = join(directory, 'package.json') + if (existsSync(manifest)) { + const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown } + if (parsed.name === name) return directory + } + const parent = dirname(directory) + if (parent === directory) throw new Error(`cannot locate package root for ${name}`) + directory = parent + } +} + +/** Copy the package files selected by the packer's import condition. */ +function mountPackage(name: string, directory: string, files: readonly string[]): void { + for (const file of files) { + const source = readFileSync(join(directory, file), 'utf8') + const path = `/dsh/node_modules/${name}/${file}` + vfs.seed(path, file.endsWith('.js') ? lowerModuleSource({ filename: path, source }).code : source) + } +} + +/** Load one consumer's exact Chokidar and readdirp versions through the Worker loader. */ +function loadChokidar(fixture: ChokidarFixture): typeof import('chokidar') { + const consumerManifest = join(process.cwd(), fixture.consumerManifest) + const chokidarEntry = createRequire(consumerManifest).resolve('chokidar') + const readdirpEntry = createRequire(chokidarEntry).resolve('readdirp') + mountPackage('chokidar', packageRoot('chokidar', chokidarEntry), fixture.chokidarFiles) + mountPackage('readdirp', packageRoot('readdirp', readdirpEntry), fixture.readdirpFiles) + const loader = new WorkerModuleLoader({ vfs, staticModules: createNodeBuiltins() }) + return loader.createRequire('/dsh/')('chokidar') as typeof import('chokidar') +} + +beforeEach(() => { + vfs = new MemoryVfs() + setActiveVfs(vfs) + vfs.mkdirSync(ROOT, { recursive: true }) +}) + +afterEach(async () => { + await Promise.all(openWatchers.splice(0).map(async (watcher) => { await watcher.close() })) +}) + +/** Await one emitter event while rejecting hangs deterministically. */ +function onceEvent(watcher: import('chokidar').FSWatcher, event: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { reject(new Error(`timed out waiting for chokidar ${event}`)) }, 2_000) + const emitter = watcher as unknown as { + once(name: string, listener: (...args: unknown[]) => void): void + } + emitter.once(event, (...args: unknown[]) => { + clearTimeout(timeout) + resolve(args[0] as T) + }) + }) +} + +/** Let watcher timers and promise-based stats reach a stable point. */ +async function delay(ms: number): Promise { + await new Promise((resolve) => { setTimeout(resolve, ms) }) +} + +/** Construct one tracked watcher with deterministic event normalization. */ +function watchPath(path: string, options: import('chokidar').ChokidarOptions = {}): import('chokidar').FSWatcher { + const watcher = chokidar.watch(path, { + ignoreInitial: true, + atomic: false, + awaitWriteFinish: false, + ...options, + }) + openWatchers.push(watcher) + return watcher +} + +describe.each(CHOKIDAR_FIXTURES)('$label running unchanged', (fixture) => { + beforeEach(() => { + chokidar = loadChokidar(fixture) + }) + + it('reaches ready and reports a file lifecycle through fs.watch', async () => { + const watcher = watchPath(ROOT, { depth: 1 }) + await onceEvent(watcher, 'ready') + + const directory = `${ROOT}/sample` + const file = `${directory}/SKILL.md` + const addDirectory = onceEvent(watcher, 'addDir') + const addFile = onceEvent(watcher, 'add') + vfs.mkdirSync(directory) + vfs.writeFileSync(file, '# sample\n') + await expect(addDirectory).resolves.toBe(directory) + await expect(addFile).resolves.toBe(file) + + const changed = onceEvent(watcher, 'change') + vfs.writeFileSync(file, '# changed\n') + await expect(changed).resolves.toBe(file) + + await new Promise((resolve) => { setTimeout(resolve, 10) }) + const removed = onceEvent(watcher, 'unlink') + vfs.rmSync(file) + await expect(removed).resolves.toBe(file) + }) + + it('watches a missing file through its existing parent', async () => { + const path = '/dsh/home/settings.yaml' + vfs.mkdirSync('/dsh/home', { recursive: true }) + const watcher = watchPath(path) + await onceEvent(watcher, 'ready') + + const added = onceEvent(watcher, 'add') + vfs.writeFileSync(path, 'theme: dark\n') + await expect(added).resolves.toBe(path) + + const removed = onceEvent(watcher, 'unlink') + vfs.rmSync(path) + await expect(removed).resolves.toBe(path) + }) + + it('discovers directory children through watchFile polling mode', async () => { + const watcher = watchPath(ROOT, { usePolling: true, interval: 5 }) + await onceEvent(watcher, 'ready') + const path = `${ROOT}/standalone.md` + const added = onceEvent(watcher, 'add') + vfs.writeFileSync(path, '# standalone\n') + await expect(added).resolves.toBe(path) + }) + + it('normalizes a short unlink/add replacement into one atomic change', async () => { + const path = `${ROOT}/atomic.md` + vfs.writeFileSync(path, 'before') + const watcher = watchPath(path, { atomic: 40 }) + await onceEvent(watcher, 'ready') + const events: string[] = [] + watcher.on('all', (event) => { events.push(event) }) + const changed = onceEvent(watcher, 'change') + vfs.rmSync(path) + await delay(5) + vfs.writeFileSync(path, 'after') + await expect(changed).resolves.toBe(path) + await delay(60) + expect(events).toEqual(['change']) + }) + + it('waits for a write burst to stabilize before publishing one add', async () => { + const path = `${ROOT}/settling.md` + const watcher = watchPath(ROOT, { + awaitWriteFinish: { stabilityThreshold: 30, pollInterval: 5 }, + }) + await onceEvent(watcher, 'ready') + const events: string[] = [] + watcher.on('all', (event) => { events.push(event) }) + const added = onceEvent(watcher, 'add') + vfs.writeFileSync(path, 'a') + await delay(10) + vfs.appendFileSync(path, 'b') + await delay(10) + vfs.appendFileSync(path, 'c') + await expect(added).resolves.toBe(path) + expect(events).toEqual(['add']) + }) + + it('emits nothing after close has reached quiescence', async () => { + const watcher = watchPath(ROOT) + const events: string[] = [] + watcher.on('all', (event) => { events.push(event) }) + await onceEvent(watcher, 'ready') + await watcher.close() + vfs.writeFileSync(`${ROOT}/after.md`, '# after\n') + await Promise.resolve() + expect(events).toEqual([]) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts new file mode 100644 index 0000000000..e095d2b6f2 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts @@ -0,0 +1,507 @@ +/** Node differential checks for the Worker filesystem watcher and stream faces. */ +import { + createReadStream as createNodeReadStream, + createWriteStream as createNodeWriteStream, + mkdtempSync, + readFileSync, + rmSync, + unwatchFile as unwatchNodeFile, + watchFile as watchNodeFile, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryVfs } from '../../src/storage/memory.ts' +import { setActiveVfs } from '../../src/storage/active.ts' +import * as workerFs from '../../src/node/builtin_modules/implemented/fs.ts' +import * as workerFsp from '../../src/node/builtin_modules/implemented/fs/promises.ts' +import * as workerStream from '../../src/node/builtin_modules/implemented/stream.ts' + +const VFS_ROOT = '/dsh/watch-stream' +const nativeRoots: string[] = [] +let vfs: MemoryVfs + +beforeEach(() => { + vfs = new MemoryVfs() + setActiveVfs(vfs) + vfs.mkdirSync(VFS_ROOT, { recursive: true }) +}) + +afterEach(() => { + for (const root of nativeRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +/** Await the next callback value with a bounded failure instead of an open watcher. */ +function nextValue(install: (resolve: (value: T) => void) => void): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { reject(new Error('timed out waiting for filesystem event')) }, 2_000) + install((value) => { + clearTimeout(timeout) + resolve(value) + }) + }) +} + +interface ReadableFileStream { + readonly bytesRead: number + on(event: string, listener: (...args: unknown[]) => void): ReadableFileStream +} + +/** Collect byte chunks and lifecycle events from one read stream implementation. */ +async function readScenario(create: () => ReadableFileStream): Promise<{ + chunks: string[] + events: string[] + bytesRead: number +}> { + const stream = create() + const chunks: string[] = [] + const events: string[] = [] + stream.on('open', () => { events.push('open') }) + stream.on('ready', () => { events.push('ready') }) + stream.on('data', (chunk: unknown) => { + events.push('data') + chunks.push(Buffer.from(chunk as Uint8Array).toString('utf8')) + }) + stream.on('end', () => { events.push('end') }) + await new Promise((resolve, reject) => { + stream.on('error', reject) + stream.on('close', () => { + events.push('close') + resolve() + }) + }) + return { chunks, events, bytesRead: stream.bytesRead } +} + +interface WritableFileStream { + readonly bytesWritten: number + on(event: string, listener: (...args: unknown[]) => void): WritableFileStream + write(chunk: string): boolean + end(chunk?: string): void +} + +/** Write the same chunks and record backpressure plus lifecycle ordering. */ +async function writeScenario(create: () => WritableFileStream): Promise<{ + writes: boolean[] + events: string[] + bytesWritten: number +}> { + const stream = create() + const events: string[] = [] + for (const event of ['open', 'ready', 'drain', 'finish'] as const) { + stream.on(event, () => { events.push(event) }) + } + const writes = [stream.write('ab'), stream.write('cd')] + stream.end('ef') + await new Promise((resolve, reject) => { + stream.on('error', reject) + stream.on('close', () => { + events.push('close') + resolve() + }) + }) + return { writes, events, bytesWritten: stream.bytesWritten } +} + +describe('file streams', () => { + it('matches Node chunking, inclusive ranges, and read lifecycle ordering', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'input.txt') + const workerPath = `${VFS_ROOT}/input.txt` + writeFileSync(nativePath, '0123456789') + vfs.writeFileSync(workerPath, '0123456789') + + const native = await readScenario(() => createNodeReadStream(nativePath, { start: 2, end: 7, highWaterMark: 2 })) + const worker = await readScenario(() => workerFs.createReadStream(workerPath, { start: 2, end: 7, highWaterMark: 2 })) + expect(worker).toEqual(native) + }) + + it('matches Node write backpressure, lifecycle ordering, and byte accounting', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'output.txt') + const workerPath = `${VFS_ROOT}/output.txt` + + const native = await writeScenario(() => createNodeWriteStream(nativePath, { highWaterMark: 2 })) + const worker = await writeScenario(() => workerFs.createWriteStream(workerPath, { highWaterMark: 2 })) + expect(worker).toEqual(native) + expect(workerFs.readFileSync(workerPath, 'utf8')).toBe('abcdef') + }) + + it('uses the maintained stream implementation for backpressure and async iteration', async () => { + const values: string[] = [] + for await (const value of workerStream.Readable.from(['one', 'two'])) values.push(String(value)) + expect(values).toEqual(['one', 'two']) + expect(typeof workerStream.pipeline).toBe('function') + expect(typeof workerStream.finished).toBe('function') + expect(workerStream.getDefaultHighWaterMark(false)).toBe(64 * 1024) + expect(workerStream.default._isArrayBufferView(new Uint8Array())).toBe(true) + }) + + it('matches Node file-stream defaults and abort error identity', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'input.txt') + const workerPath = `${VFS_ROOT}/input.txt` + writeFileSync(nativePath, 'content') + vfs.writeFileSync(workerPath, 'content') + const nativeRead = createNodeReadStream(nativePath) + const nativeWrite = createNodeWriteStream(join(nativeRoot, 'output.txt')) + const workerRead = workerFs.createReadStream(workerPath) + const workerWrite = workerFs.createWriteStream(`${VFS_ROOT}/output.txt`) + expect([workerRead.readableHighWaterMark, workerWrite.writableHighWaterMark]).toEqual([ + nativeRead.readableHighWaterMark, + nativeWrite.writableHighWaterMark, + ]) + interface CloseableStream { + once(event: string, listener: (...args: unknown[]) => void): unknown + destroy(): unknown + } + const streams = [nativeRead, nativeWrite, workerRead, workerWrite] as unknown as CloseableStream[] + const closed = streams.map(stream => new Promise((resolve) => { + stream.once('error', () => {}) + stream.once('close', () => { resolve() }) + })) + for (const stream of streams) stream.destroy() + await Promise.all(closed) + + const controller = new AbortController() + controller.abort(new Error('stop')) + const aborted = workerFs.createReadStream(workerPath, { signal: controller.signal }) + const error = await nextValue((resolve) => { aborted.once('error', resolve) }) + expect(error).toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + }) + + it('matches Node positional overwrite and missing-file failure', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'position.txt') + const workerPath = `${VFS_ROOT}/position.txt` + writeFileSync(nativePath, 'abcdef') + vfs.writeFileSync(workerPath, 'abcdef') + + const writeAt = async (stream: WritableFileStream): Promise => { + stream.end('XY') + await new Promise((resolve) => { stream.on('close', () => { resolve() }) }) + } + await writeAt(createNodeWriteStream(nativePath, { flags: 'r+', start: 2 })) + await writeAt(workerFs.createWriteStream(workerPath, { flags: 'r+', start: 2 })) + expect(workerFs.readFileSync(workerPath, 'utf8')).toBe(readFileSync(nativePath, 'utf8')) + + const missing = workerFs.createReadStream(`${VFS_ROOT}/missing.txt`) + const events: string[] = [] + missing.on('error', () => { events.push('error') }) + await new Promise((resolve) => { + missing.on('close', () => { + events.push('close') + resolve() + }) + }) + expect(events).toEqual(['error', 'close']) + }) +}) + +interface StatTransition { + currentExists: boolean + previousExists: boolean + currentSize: number + previousSize: number + currentOtherKinds: boolean[] +} + +/** Observe missing, creation, rewrite, and deletion through one watchFile implementation. */ +async function watchFileScenario( + path: string, + watchFile: typeof watchNodeFile, + unwatchFile: typeof unwatchNodeFile, + write: (text: string) => void, + remove: () => void, +): Promise { + const waiting: Array<(value: StatTransition) => void> = [] + const queued: StatTransition[] = [] + const listener = (current: import('node:fs').Stats, previous: import('node:fs').Stats): void => { + const transition = { + currentExists: current.isFile(), + previousExists: previous.isFile(), + currentSize: current.size, + previousSize: previous.size, + currentOtherKinds: [ + current.isDirectory(), current.isSymbolicLink(), current.isFIFO(), + current.isSocket(), current.isBlockDevice(), current.isCharacterDevice(), + ], + } + const resolve = waiting.shift() + if (resolve === undefined) queued.push(transition) + else resolve(transition) + } + const next = async (): Promise => { + const queuedValue = queued.shift() + if (queuedValue !== undefined) return queuedValue + return await nextValue((resolve) => { waiting.push(resolve) }) + } + watchFile(path, { interval: 10, persistent: false }, listener) + try { + const missing = await next() + write('a') + const created = await next() + write('longer') + const changed = await next() + remove() + const removed = await next() + return [missing, created, changed, removed] + } finally { + unwatchFile(path, listener) + } +} + +describe('watchers', () => { + it('matches Node watchFile state transitions for a missing and recreated file', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-watch-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'watched.txt') + const workerPath = `${VFS_ROOT}/watched.txt` + const native = await watchFileScenario( + nativePath, + watchNodeFile, + unwatchNodeFile, + (text) => { writeFileSync(nativePath, text) }, + () => { rmSync(nativePath) }, + ) + const worker = await watchFileScenario( + workerPath, + workerFs.watchFile as unknown as typeof watchNodeFile, + workerFs.unwatchFile as unknown as typeof unwatchNodeFile, + (text) => { vfs.writeFileSync(workerPath, text) }, + () => { vfs.rmSync(workerPath) }, + ) + expect(worker).toEqual(native) + }) + + it('shares one StatWatcher and removes only the named listener', async () => { + const path = `${VFS_ROOT}/shared.txt` + vfs.writeFileSync(path, 'a') + const firstEvents: number[] = [] + const secondEvents: number[] = [] + const first = (): void => { firstEvents.push(1) } + const second = (): void => { secondEvents.push(1) } + const firstWatcher = workerFs.watchFile(path, { interval: 1, persistent: false }, first) + const secondWatcher = workerFs.watchFile(path, { interval: 1, persistent: false }, second) + expect(secondWatcher).toBe(firstWatcher) + workerFs.unwatchFile(path, first) + vfs.writeFileSync(path, 'bb') + await nextValue((resolve) => { + const poll = setInterval(() => { + if (secondEvents.length === 0) return + clearInterval(poll) + resolve(undefined) + }, 1) + }) + expect(firstEvents).toEqual([]) + expect(secondEvents).toEqual([1]) + workerFs.unwatchFile(path) + }) + + it('reports direct and recursive names, then reaches quiescence on close', async () => { + const root = `${VFS_ROOT}/tree` + vfs.mkdirSync(`${root}/nested`, { recursive: true }) + const directEvents: Array<[string, string]> = [] + const recursiveEvents: Array<[string, string]> = [] + const direct = workerFs.watch(root, (_event, _filename) => {}) + direct.on('change', (event, filename) => { directEvents.push([String(event), String(filename)]) }) + const recursive = workerFs.watch(root, { recursive: true }, (event, filename) => { + recursiveEvents.push([event, String(filename)]) + }) + vfs.writeFileSync(`${root}/top.txt`, 'top') + vfs.writeFileSync(`${root}/nested/deep.txt`, 'deep') + await Promise.resolve() + expect(directEvents).toEqual([['rename', 'top.txt']]) + expect(recursiveEvents).toEqual([ + ['rename', 'top.txt'], + ['rename', 'nested/deep.txt'], + ]) + direct.close() + recursive.close() + vfs.writeFileSync(`${root}/after.txt`, 'after') + await Promise.resolve() + expect(directEvents).toHaveLength(1) + expect(recursiveEvents).toHaveLength(2) + }) + + it('supports Buffer filenames, file targets, abort closure, and ref state', async () => { + const path = `${VFS_ROOT}/encoded.txt` + vfs.writeFileSync(path, 'before') + const controller = new AbortController() + const event = nextValue<[string, Buffer]>((resolve) => { + const watcher = workerFs.watch( + new TextEncoder().encode(path), + { encoding: 'buffer', persistent: false, signal: controller.signal }, + (eventType, filename) => { resolve([eventType, filename as Buffer]) }, + ) + expect(watcher.hasRef()).toBe(false) + expect(watcher.ref().hasRef()).toBe(true) + expect(watcher.unref().hasRef()).toBe(false) + }) + vfs.writeFileSync(path, 'after') + const [eventType, filename] = await event + expect(eventType).toBe('change') + expect(Buffer.isBuffer(filename)).toBe(true) + expect(filename.toString()).toBe('encoded.txt') + + const watcher = workerFs.watch(path, { signal: controller.signal }) + let closes = 0 + const closed = nextValue((resolve) => { + watcher.on('close', () => { + closes += 1 + resolve(undefined) + }) + }) + controller.abort(new Error('stop')) + await closed + watcher.close() + await Promise.resolve() + expect(closes).toBe(1) + }) + + it('supports the string encoding overload and suppresses queued delivery after close', async () => { + const encoded = nextValue((resolve) => { + const watcher = workerFs.watch(VFS_ROOT, 'buffer', (_eventType, filename) => { + watcher.close() + resolve(filename as Buffer) + }) + }) + vfs.writeFileSync(`${VFS_ROOT}/buffer-name.txt`, 'x') + await expect(encoded).resolves.toEqual(Buffer.from('buffer-name.txt')) + + let calls = 0 + const closed = workerFs.watch(VFS_ROOT, () => { calls += 1 }) + vfs.writeFileSync(`${VFS_ROOT}/queued.txt`, 'x') + closed.close() + await Promise.resolve() + expect(calls).toBe(0) + }) + + it('reports removal of an ancestor to a watched file', async () => { + const directory = `${VFS_ROOT}/removed-parent` + const path = `${directory}/file.txt` + vfs.mkdirSync(directory) + vfs.writeFileSync(path, 'x') + const event = nextValue<[string, string]>((resolve) => { + const watcher = workerFs.watch(path, (eventType, filename) => { + watcher.close() + resolve([eventType, String(filename)]) + }) + }) + vfs.rmSync(directory, { recursive: true }) + await expect(event).resolves.toEqual(['rename', 'file.txt']) + }) + + it('rejects an already-aborted callback watcher without retaining a subscription', () => { + const controller = new AbortController() + const reason = new Error('already stopped') + controller.abort(reason) + try { + workerFs.watch(VFS_ROOT, { signal: controller.signal }) + throw new Error('watch unexpectedly opened') + } catch (error) { + expect(error).toMatchObject({ name: 'AbortError', code: 'ABORT_ERR', cause: reason }) + } + expect(() => { vfs.writeFileSync(`${VFS_ROOT}/after-abort.txt`, 'x') }).not.toThrow() + }) + + it('reports an atomic replacement destination as rename even when it existed', async () => { + const target = `${VFS_ROOT}/target.txt` + const replacement = `${VFS_ROOT}/replacement.txt` + vfs.writeFileSync(target, 'old') + vfs.writeFileSync(replacement, 'new') + const event = nextValue<[string, string]>((resolve) => { + const watcher = workerFs.watch(VFS_ROOT, (eventType, filename) => { + if (String(filename) !== 'target.txt') return + watcher.close() + resolve([eventType, String(filename)]) + }) + }) + vfs.renameSync(replacement, target) + await expect(event).resolves.toEqual(['rename', 'target.txt']) + }) + + it('supports BigInt watchFile state, default options, and idempotent stop', async () => { + const path = `${VFS_ROOT}/bigint.txt` + const states = nextValue<[bigint, bigint]>((resolve) => { + const watcher = workerFs.watchFile(new URL(`file://${path}`), { bigint: true, interval: 1 }, (current, previous) => { + resolve([current.size as bigint, previous.size as bigint]) + }) + expect(watcher.hasRef()).toBe(true) + expect(watcher.unref().hasRef()).toBe(false) + expect(watcher.ref().hasRef()).toBe(true) + }) + vfs.writeFileSync(path, 'big') + await expect(states).resolves.toEqual([3n, 0n]) + workerFs.unwatchFile(path) + workerFs.unwatchFile(path) + + vfs.writeFileSync(`${VFS_ROOT}/default.txt`, 'x') + const listener = (): void => {} + const defaultWatcher = workerFs.watchFile(`${VFS_ROOT}/default.txt`, listener) + expect(defaultWatcher.hasRef()).toBe(true) + defaultWatcher.close() + defaultWatcher.close() + expect(() => workerFs.watchFile(`${VFS_ROOT}/default.txt`, {})).toThrow(/listener/) + + let cancelledCalls = 0 + const cancelled = workerFs.watchFile(`${VFS_ROOT}/never-created`, { interval: 1 }, () => { cancelledCalls += 1 }) + cancelled.close() + cancelled.close() + await new Promise((resolve) => { setTimeout(resolve, 5) }) + expect(cancelledCalls).toBe(0) + }) + + it('propagates non-absence stat failures from watchFile', () => { + const failure = Object.assign(new Error('denied'), { code: 'EACCES' }) + vi.spyOn(vfs, 'statSync').mockImplementationOnce(() => { throw failure }) + expect(() => workerFs.watchFile(`${VFS_ROOT}/denied`, () => {})).toThrow(failure) + }) + + it('exposes promise watch as an abortable async iterator', async () => { + const controller = new AbortController() + const iterator = workerFsp.watch(VFS_ROOT, { signal: controller.signal })[Symbol.asyncIterator]() + const event = iterator.next() + vfs.writeFileSync(`${VFS_ROOT}/async.txt`, 'x') + await expect(event).resolves.toEqual({ done: false, value: { eventType: 'rename', filename: 'async.txt' } }) + controller.abort() + await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + }) + + it('lets promise-watch return interrupt a pending next call', async () => { + const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() + const pending = iterator.next() + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + vfs.writeFileSync(`${VFS_ROOT}/after-return.txt`, 'x') + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('propagates promise-watch startup and throw failures', async () => { + const missing = workerFsp.watch(`${VFS_ROOT}/missing`)[Symbol.asyncIterator]() + await expect(missing.next()).rejects.toMatchObject({ code: 'ENOENT' }) + + const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() + const reason = { reason: 'caller stopped iteration' } + if (iterator.throw === undefined) throw new Error('watch iterator has no throw method') + await expect(iterator.throw(reason)).rejects.toBe(reason) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('queues promise-watch events when no next call is waiting', async () => { + const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() + const first = iterator.next() + vfs.writeFileSync(`${VFS_ROOT}/one.txt`, 'one') + vfs.writeFileSync(`${VFS_ROOT}/two.txt`, 'two') + await expect(first).resolves.toEqual({ done: false, value: { eventType: 'rename', filename: 'one.txt' } }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: { eventType: 'rename', filename: 'two.txt' } }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts index 400e01ee92..ce9e6fd916 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts @@ -12,9 +12,17 @@ import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/s import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts' import * as fs from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs.ts' import * as fsp from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts' -import type { VfsBigIntStats, VfsStats } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types.ts' +import type { VfsBigIntStats, VfsMutationSink, VfsStats } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types.ts' -const vfs = new MemoryVfs() +let flushes = 0 +const sink: VfsMutationSink = { + record: () => {}, + flush: () => { + flushes += 1 + return Promise.resolve() + }, +} +const vfs = new MemoryVfs({ sink }) setActiveVfs(vfs) // Identity precondition: the bridge must read this exact mounted VFS; successful @@ -76,9 +84,6 @@ throws('readFileSync missing', () => fs.readFileSync('/dsh/missing'), 'ENOENT') throws('statSync missing', () => fs.statSync('/dsh/missing'), 'ENOENT') throws('accessSync missing', () =>{ fs.accessSync('/dsh/missing') }, 'ENOENT') throws('readdirSync missing', () => fs.readdirSync('/dsh/missing'), 'ENOENT') -throws('watchFile is loud', () => fs.watchFile('/dsh/config/cordis.yml'), 'not implemented') -throws('createReadStream is loud', () => fs.createReadStream('/dsh/config/cordis.yml'), 'not implemented') - const appendFd = fs.openSync('/dsh/log.jsonl', 'a') fs.writeSync(appendFd, '{"a":1}\n') fs.writeSync(appendFd, '{"a":2}\n') @@ -112,6 +117,7 @@ const appendHandle = await fsp.open('/dsh/log-handle.jsonl', 'a') check('append handle sees the existing size', (await appendHandle.stat()).size, 7) await appendHandle.writeFile('batch-1\n') await appendHandle.sync() +check('handle.sync flushes the active VFS', flushes, 1) await appendHandle.close() const secondHandle = await fsp.open('/dsh/log-handle.jsonl', 'a') await secondHandle.writeFile('batch-2\n') diff --git a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts index ef52920292..35d6f32460 100644 --- a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts @@ -16,17 +16,14 @@ import { notAvailableError, notImplementedFail } from '../../src/node/notImpleme import * as childProcess from '../../src/node/builtin_modules/implemented/child_process.ts' import * as net from '../../src/node/builtin_modules/mock/net.ts' import * as sqlite from '../../src/node/builtin_modules/mock/sqlite.ts' -import * as stream from '../../src/node/builtin_modules/mock/stream.ts' +import * as stream from '../../src/node/builtin_modules/implemented/stream.ts' import * as vm from '../../src/node/builtin_modules/mock/vm.ts' import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts' -import * as chokidar from '../../src/node/external_packages/chokidar.ts' -import * as landlock from '../../src/node/external_packages/node-addon-landlock-run.ts' import * as nodePty from '../../src/node/external_packages/node-pty.ts' import * as piAi from '../../src/node/external_packages/pi-ai.ts' import * as ripgrep from '../../src/node/external_packages/ripgrep.ts' import * as ws from '../../src/node/external_packages/ws.ts' import { REPLACED_EXTERNAL_PACKAGES } from '../../src/node/external_packages/replaced-externals.ts' -import * as fs from '../../src/node/builtin_modules/implemented/fs.ts' import * as os from '../../src/node/builtin_modules/implemented/os.ts' import * as perfHooks from '../../src/node/builtin_modules/implemented/perf_hooks.ts' import { DSH_HOME, DSH_TMP } from '../../src/storage/paths.ts' @@ -43,9 +40,7 @@ const CALLED: [string, Record, readonly string[]][] = [ // The rest of `node:child_process` runs commands (see child-process.spec.ts); // these three need a real process, so they stay refusals. ['node:child_process', childProcess, ['execFileSync', 'execSync', 'fork']], - ['node:stream', stream, ['Readable', 'Writable', 'Duplex', 'Transform', 'PassThrough', 'pipeline', 'finished']], ['node-pty', nodePty, ['spawn', 'open']], - ['@deepseek-ai/node-addon-landlock-run', landlock, ['probe']], ['@deepseek-ai/pi-ai', piAi, [ 'createProvider', 'createModels', 'openAICompletionsApi', 'openAIResponsesApi', 'anthropicMessagesApi', 'isContextOverflow', 'getSupportedThinkingLevels', @@ -95,7 +90,7 @@ describe('not-implemented stubs', () => { } it('keeps the CommonJS interop marker and a default export on every replaced module', () => { - for (const namespace of [net, sqlite, vm, workerThreads, childProcess, stream, chokidar, ws, nodePty, piAi, os, perfHooks]) { + for (const namespace of [net, sqlite, vm, workerThreads, childProcess, stream, ws, nodePty, piAi, os, perfHooks]) { const holder = namespace as { __esModule?: unknown; default?: unknown } expect(holder.__esModule).toBe(true) expect(holder.default).toBeDefined() @@ -104,19 +99,6 @@ describe('not-implemented stubs', () => { }) describe('constructible-but-inert fakes', () => { - // These two are constructed in `[Service.init]` bodies and field initializers, - // so construction must succeed; only the members that would move bytes refuse. - it('chokidar watches nothing and says so by never emitting', async () => { - const watcher = chokidar.watch() - expect(watcher).toBeInstanceOf(chokidar.FSWatcher) - expect(watcher.on()).toBe(watcher) - expect(watcher.once()).toBe(watcher) - expect(watcher.add()).toBe(watcher) - expect(watcher.unwatch()).toBe(watcher) - expect(watcher.getWatched()).toEqual({}) - await expect(watcher.close()).resolves.toBeUndefined() - }) - it('a ws server constructs, accepts listeners, and refuses to carry an upgrade', () => { quiet() expect(ws.Server).toBe(ws.WebSocketServer) @@ -133,16 +115,14 @@ describe('constructible-but-inert fakes', () => { describe('replaced external packages', () => { it('lists the packages the loader serves from the bundle', () => { - expect(REPLACED_EXTERNAL_PACKAGES).toContain('chokidar') + expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('chokidar') + expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('@deepseek-ai/node-addon-landlock-run') expect(REPLACED_EXTERNAL_PACKAGES).toContain('ws') }) it('answers the values callers read without invoking anything', () => { - // The ripgrep binary path and the landlock launcher are read as data by - // consumers that then fail on their own terms. + // The ripgrep binary path is read as data by its consumer. expect(typeof ripgrep.rgPath).toBe('string') - expect(typeof landlock.LAUNCHER_BIN).toBe('string') - expect(typeof landlock.LAUNCHER_FAILURE_EXIT).toBe('number') }) }) @@ -190,17 +170,3 @@ describe('node:perf_hooks', () => { expect(perfHooks.performance.now()).toBeGreaterThan(0) }) }) - -describe('watching', () => { - // Watching stays a loud refusal because `skill-filesystem` AWAITS watcher - // progress rather than merely registering a listener; an inert watcher left - // its discovery hanging. `fs.ts` records the experiment and the mechanism. - it('refuses, naming the member, so an awaiting caller fails fast', () => { - quiet() - expect(() => fs.watchFile('/dsh/config/cordis.yml')).toThrow(/watchFile is not implemented in the worker host/) - }) - - it('accepts the unconditional teardown call, since nothing was watched', () => { - expect(() => { fs.unwatchFile() }).not.toThrow() - }) -}) diff --git a/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts b/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts new file mode 100644 index 0000000000..599608d598 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts @@ -0,0 +1,98 @@ +/** The unchanged sandbox-local → bash-sandbox → subprocess stack over the Worker Node layer. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSandboxProvider from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import { MemoryVfs } from '../../src/storage/memory.ts' +import { setActiveVfs } from '../../src/storage/active.ts' +import { processAlive, signalProcess } from '../../src/node/process-table.ts' + +vi.mock('node:child_process', async () => await import('../../src/node/builtin_modules/implemented/child_process.ts')) + +const WORKSPACE = '/dsh/workspace' +const OUTSIDE = '/dsh/home' +let vfs: MemoryVfs +const contexts: Context[] = [] + +beforeEach(() => { + vfs = new MemoryVfs() + setActiveVfs(vfs) + vfs.mkdirSync(WORKSPACE, { recursive: true }) + vfs.mkdirSync(OUTSIDE, { recursive: true }) + vfs.mkdirSync('/dsh/tmp', { recursive: true }) + vi.spyOn(process, 'kill').mockImplementation((pid: number, signal?: string | number): true => { + if (signal === 0) { + if (processAlive(pid)) return true + const error = new Error('kill ESRCH') as NodeJS.ErrnoException + error.code = 'ESRCH' + throw error + } + signalProcess(pid, (signal ?? 'SIGTERM') as NodeJS.Signals) + return true + }) +}) + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(async (ctx) => { await ctx.fiber.dispose() })) + vi.restoreAllMocks() +}) + +/** Boot the production providers while only their platform primitives are replaced. */ +async function setup(mode: 'read-only' | 'workspace-write' | 'danger-full-access'): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LocalSandboxProvider) + await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: WORKSPACE }) + await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(SandboxBashExecutor, { cwd: WORKSPACE }) + return ctx.shell as SandboxBashExecutor +} + +describe('Worker Landlock through the production sandbox stack', () => { + it('allows workspace and temp writes while classifying an outside write as denied', async () => { + const bash = await setup('workspace-write') + const allowed = await bash.run(bash.resolve({ + command: `echo workspace > ${WORKSPACE}/allowed.txt; echo temp > /tmp/allowed.txt`, + })) + expect(allowed.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + expect(vfs.readFileSync(`${WORKSPACE}/allowed.txt`, 'utf8')).toBe('workspace\n') + expect(vfs.readFileSync('/dsh/tmp/allowed.txt', 'utf8')).toBe('temp\n') + + const denied = await bash.run(bash.resolve({ command: `echo denied > ${OUTSIDE}/denied.txt` })) + expect(denied.exitCode).toBe(1) + expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' }) + expect(vfs.existsSync(`${OUTSIDE}/denied.txt`)).toBe(false) + }) + + it('keeps read-only confined and danger-full-access unwrapped', async () => { + const readOnly = await setup('read-only') + const strict = await readOnly.run(readOnly.resolve({ + command: `echo discarded > /dev/null; echo denied > ${WORKSPACE}/strict.txt`, + })) + expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false) + + const unrestricted = await setup('danger-full-access') + const result = await unrestricted.run(unrestricted.resolve({ command: `echo allowed > ${OUTSIDE}/full.txt` })) + expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) + expect(vfs.readFileSync(`${OUTSIDE}/full.txt`, 'utf8')).toBe('allowed\n') + }) + + it('does not leak a concurrent command policy into another process', async () => { + const bash = await setup('read-only') + const strict = bash.run(bash.resolve({ + command: `sleep 0.02; echo denied > ${WORKSPACE}/strict.txt`, + })) + const writable = bash.run(bash.resolve({ + command: `echo allowed > ${WORKSPACE}/writable.txt`, + sandboxPolicy: { mode: 'workspace-write', workspaceRoot: WORKSPACE }, + })) + const [strictResult, writableResult] = await Promise.all([strict, writable]) + expect(strictResult.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + expect(writableResult.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false) + expect(vfs.readFileSync(`${WORKSPACE}/writable.txt`, 'utf8')).toBe('allowed\n') + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index 1068cfdf21..8278e6c153 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -1,6 +1,7 @@ /** - * The identity, timestamp, and link guarantees MemoryVfs owes its consumers, - * asserted on the filesystem directly rather than through the `node:fs` bridge. + * The identity, timestamp, link, mutation, and durability-sink guarantees + * MemoryVfs owes its consumers, asserted directly rather than through the + * `node:fs` bridge. * * `dsh-fs-local` builds a version token from `dev:ino:size:mtimeNs:ctimeNs` and * refuses a write whose token moved since it read. Two properties carry that: @@ -11,7 +12,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryVfs } from '../../src/storage/memory.ts' -import type { VfsBigIntStats, VfsStats } from '../../src/storage/types.ts' +import type { VfsBigIntStats, VfsMutation, VfsMutationSink, VfsStats } from '../../src/storage/types.ts' const identity = (vfs: MemoryVfs, path: string): bigint => (vfs.statSync(path, { bigint: true }) as VfsBigIntStats).ino @@ -55,6 +56,16 @@ describe('entry identity', () => { }) describe('modification time', () => { + it('hydrates explicit metadata without confusing timestamps with permission bits', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/restored', 'value', { mode: 0o600, mtimeMs: 1_600_000_000_000 }) + vfs.seedDirectory('/dsh/restored-directory', { mode: 0o700, mtimeMs: 1_600_000_000_001 }) + const stats = vfs.statSync('/dsh/restored') as VfsStats + const directory = vfs.statSync('/dsh/restored-directory') as VfsStats + expect([stats.mode & 0o777, stats.mtimeMs]).toEqual([0o600, 1_600_000_000_000]) + expect([directory.mode & 0o777, directory.mtimeMs]).toEqual([0o700, 1_600_000_000_001]) + }) + it('advances on every write even while the clock stands still', () => { vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) const vfs = new MemoryVfs() @@ -80,6 +91,108 @@ describe('modification time', () => { vfs.writeFileSync('/dsh/log.jsonl', 'second\n') expect(modified(vfs, '/dsh/log.jsonl')).toBe(1_700_000_005_000) }) + + it('advances a directory only when its immediate entry set changes', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + const vfs = new MemoryVfs() + vfs.seedDirectory('/dsh/workspace') + const empty = modified(vfs, '/dsh/workspace') + vfs.writeFileSync('/dsh/workspace/file.txt', 'one') + const created = modified(vfs, '/dsh/workspace') + vfs.writeFileSync('/dsh/workspace/file.txt', 'two') + const rewritten = modified(vfs, '/dsh/workspace') + vfs.rmSync('/dsh/workspace/file.txt') + const removed = modified(vfs, '/dsh/workspace') + expect([created > empty, rewritten === created, removed > rewritten]).toEqual([true, true, true]) + }) +}) + +describe('mutation publication', () => { + it('publishes only committed runtime changes and keeps image seeding silent', () => { + const vfs = new MemoryVfs() + const mutations: VfsMutation[] = [] + vfs.subscribe((mutation) => { mutations.push(mutation) }) + vfs.seed('/dsh/seeded.txt', 'seeded') + expect(mutations).toEqual([]) + vfs.writeFileSync('/dsh/seeded.txt', 'changed') + vfs.mkdirSync('/dsh/created') + vfs.chmodSync('/dsh/created', 0o700) + vfs.renameSync('/dsh/seeded.txt', '/dsh/renamed.txt') + vfs.rmSync('/dsh/created', { recursive: true }) + expect(mutations.map(mutation => ({ + kind: mutation.kind, + path: mutation.path, + ...mutation.kind === 'write' ? { entryChanged: mutation.entryChanged } : {}, + ...mutation.kind === 'chmod' ? { mode: mutation.mode } : {}, + }))).toEqual([ + { kind: 'write', path: '/dsh/seeded.txt', entryChanged: false }, + { kind: 'mkdir', path: '/dsh/created' }, + { kind: 'chmod', path: '/dsh/created', mode: 0o700 }, + { kind: 'remove', path: '/dsh/seeded.txt' }, + { kind: 'write', path: '/dsh/renamed.txt', entryChanged: true }, + { kind: 'remove', path: '/dsh/created' }, + ]) + const renamed = mutations[4] + expect(renamed?.kind === 'write' && new TextDecoder().decode(renamed.bytes)).toBe('changed') + expect(() => { vfs.writeFileSync('/missing/file', 'no') }).toThrow(/ENOENT/) + expect(mutations).toHaveLength(6) + }) + + it('contains a faulty observer and lets disposal stop later notifications', () => { + const vfs = new MemoryVfs() + vfs.seedDirectory('/dsh') + const reported = vi.spyOn(console, 'error').mockImplementation(() => {}) + const first = vfs.subscribe(() => { throw new Error('observer failed') }) + const seen: string[] = [] + const second = vfs.subscribe((mutation) => { seen.push(mutation.path) }) + vfs.writeFileSync('/dsh/one', '1') + first() + second() + vfs.writeFileSync('/dsh/two', '2') + expect(seen).toEqual(['/dsh/one']) + expect(reported).toHaveBeenCalledOnce() + }) + + it('feeds the same complete mutations to a durable sink and live subscribers', async () => { + const recorded: VfsMutation[] = [] + let flushes = 0 + const sink: VfsMutationSink = { + record: (mutation) => { recorded.push(mutation) }, + flush: async () => { flushes += 1 }, + } + const vfs = new MemoryVfs({ sink }) + vfs.seedDirectory('/dsh') + const observed: VfsMutation[] = [] + vfs.subscribe((mutation) => { observed.push(mutation) }) + vfs.writeFileSync('/dsh/log', 'a') + vfs.appendFileSync('/dsh/log', 'bc') + await vfs.flush() + expect(observed).toEqual(recorded) + expect(observed[0]).toBe(recorded[0]) + expect(recorded[0]).toMatchObject({ kind: 'write', path: '/dsh/log', mode: 0o644, entryChanged: true }) + expect(recorded[1]).toMatchObject({ kind: 'write', path: '/dsh/log', mode: 0o644, entryChanged: false, appendedFrom: 1 }) + expect(recorded[1]?.kind === 'write' && new TextDecoder().decode(recorded[1].bytes)).toBe('abc') + expect(flushes).toBe(1) + }) + + it('decomposes a directory rename into replayable destination state', () => { + const recorded: VfsMutation[] = [] + const vfs = new MemoryVfs({ + sink: { record: (mutation) => { recorded.push(mutation) }, flush: () => Promise.resolve() }, + }) + vfs.seedDirectory('/dsh/staging/nested', { mode: 0o700 }) + vfs.seed('/dsh/staging/nested/file', 'value', { mode: 0o600 }) + vfs.renameSync('/dsh/staging', '/dsh/published') + + expect(recorded.map(mutation => [mutation.kind, mutation.path])).toEqual([ + ['remove', '/dsh/staging'], + ['mkdir', '/dsh/published'], + ['mkdir', '/dsh/published/nested'], + ['write', '/dsh/published/nested/file'], + ]) + expect(recorded[3]).toMatchObject({ kind: 'write', mode: 0o600, entryChanged: true }) + expect(recorded[3]?.kind === 'write' && new TextDecoder().decode(recorded[3].bytes)).toBe('value') + }) }) describe('hard links', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5f3e827dd..5c25bbf2ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4814,6 +4814,9 @@ importers: picomatch: specifier: ^4.0.4 version: 4.0.4 + readable-stream: + specifier: ^4.7.0 + version: 4.7.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4824,6 +4827,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../../api/gateway + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../shell/bash-sandbox '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules @@ -4836,12 +4842,27 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:^ + version: link:../../../native/landlock-run/packages/entry '@types/picomatch': specifier: ^3.0.2 version: 3.0.2 + '@types/readable-stream': + specifier: ^4.0.24 + version: 4.0.24 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 packages/extensions/cordis-client-runner: devDependencies: @@ -12641,6 +12662,9 @@ packages: '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/readable-stream@4.0.24': + resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -12892,6 +12916,10 @@ packages: resolution: {integrity: sha512-WoxUM/Be4hfsX06FxsvpGgfYqwgivMV7/Ol7aFuSfSmY6rRaiju4QxOEe9RUS0iYcSHWl5i9AhB1cMoE0p+XiA==} engines: {node: '>=18.12.0'} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -13583,9 +13611,17 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -14818,6 +14854,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -14875,6 +14915,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -15122,6 +15166,9 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -17914,6 +17961,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/readable-stream@4.0.24': + dependencies: + '@types/node': 22.20.0 + '@types/retry@0.12.0': {} '@types/spdx-expression-parse@4.0.0': {} @@ -18191,6 +18242,10 @@ snapshots: js-yaml: 4.3.1 tslib: 2.8.1 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -18951,8 +19006,12 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + events@3.3.0: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: @@ -20426,6 +20485,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + property-information@7.2.0: {} protobufjs@7.6.4: @@ -20498,6 +20559,14 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -20840,6 +20909,10 @@ snapshots: dependencies: safe-buffer: 5.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 From 181a0e18ef41f2b146c4d604a3d38988db9c8473 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:09:20 +0800 Subject: [PATCH 018/138] docs(webworker): define the preview example seed --- ...6-08-20-webworker-pack-lowering-and-preview.i18n.yaml | 4 ++-- .../2026-08-20-webworker-pack-lowering-and-preview.md | 9 +++++++++ .../2026-08-20-webworker-pack-lowering-and-preview.zh.md | 9 +++++++++ packages/experimental/webworker-packer/README.i18n.yaml | 4 ++-- packages/experimental/webworker-packer/README.md | 4 +++- packages/experimental/webworker-packer/README.zh.md | 4 +++- packages/experimental/webworker-runtime/README.i18n.yaml | 4 ++-- packages/experimental/webworker-runtime/README.md | 6 +++--- packages/experimental/webworker-runtime/README.zh.md | 6 +++--- 9 files changed, 36 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml index b93c9676cb..b4cef0f471 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.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-20-webworker-pack-lowering-and-preview.md -2026-08-20-webworker-pack-lowering-and-preview.md: 37c7730c5da664560156a8e4bd9ba594c78369ee -2026-08-20-webworker-pack-lowering-and-preview.zh.md: 09d9356ee8744ba2406bd765a6ca6a060f93853a +2026-08-20-webworker-pack-lowering-and-preview.md: 72a6ccf856c95f835e103bd355223bf3cf42f692 +2026-08-20-webworker-pack-lowering-and-preview.zh.md: 074d44833c34a2999a5c47da809533065201b580 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md index 37c7730c5d..72a6ccf856 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md @@ -14,6 +14,8 @@ The browser worker can neither compile modules at load nor be served by the prod **The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `` at serve time, keeping the on-disk pages byte-shared. +**The repository preview carries selectable filesystem sources.** The packer emits one base image and a small overlay archive for each named built-in fixture. Without a source query, `preview.html` waits at a chooser for an empty filesystem, the built-in fixtures, or the separately owned WebFS provider. A valid `preview-fixture=none|` query selects directly and skips the chooser for deterministic browser runs; its distinct name avoids the Client's existing `fixture` transport switch. The Worker mounts the base and then applies the selected overlays in order, restricted to `home/` and `workspace/`, before it validates the base manifest or boots Cordis. `packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` supplies one built-in overlay without giving the packer Session or Workspace knowledge. Its plaintext JSONL logs use the persistence backend's real project/session directory layout, so Session Persistence reads them cold and Workspace Registry derives the Workspace from their `/dsh/workspace` headers. The main Session exceeds the Client's 50-message page and keeps representative tool results at its tail; persisted one-shot and continuable children exercise the subagent catalog. WebFS authorization and user data remain a separate provider and never share this fixture tree. + Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental-*`, private and outside official releases. The boundary that carries product promises stays in the product packages: the injection table, `__DSH_TRANSPORT__`, and the `/plugins` bundle bytes are owned by `dsh-host-webserver`, `dsh-client-modules`, and `dsh-client-connection`. ## Alternatives considered @@ -26,6 +28,12 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental **Gating the stock entry on top-level await ordering instead of a deferred.** Sibling module scripts do not wait for one another's top-level awaits; the `??=`-installed deferred makes the handshake order-independent and lets a failed handshake reject into the boot page's failure rendering. +**Generate example state in the Worker at startup.** A preview-only Session or Workspace creation branch would bypass cold persistence loading and make the runtime own test data. Static image files exercise the same discovery and pagination path as existing user data. + +**Seed the example through WebFS.** WebFS owns user-selected durable storage and its lifecycle. Coupling the built-in demonstration to it would make a static preview depend on browser persistence state and would blur which bytes came from the deployment. + +**Pack one complete base image per fixture.** Full-image variants duplicate the runtime package closure and make combinations quadratic. Restricted overlays keep one immutable base, let the chooser compose zero or more data sources, and give future WebFS hydration the same pre-boot application point. + ## Consequences - `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed). @@ -33,3 +41,4 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental - The transform corpus imports every built bundle through Node before comparing its lowered exports. Its pinned exemptions name the actual non-importable bundle and fail when one becomes importable: after Win32 process primitives became the Koffi type owner, `win32-process` carries the duplicate-type exemption and `sandbox-windows-acl` does not. - The served `` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base. - The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads. +- The preview waits at a pre-boot source chooser. Its built-in example opens a reproducible Workspace and cold Session corpus suitable for inspecting tool cards, subagent navigation, and backward pagination without credentials or model calls; the empty selection preserves first-run coverage. Fixture tests validate the physical logs through production readers, and browser acceptance verifies both selections. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md index 09d9356ee8..074d44833c 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md @@ -14,6 +14,8 @@ **preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。 +**仓库 preview 携带可选择的文件系统来源。** Packer 产出一份基础镜像,并为每套具名内置 fixture 产出一份小型 overlay 归档。没有来源 query 时,`preview.html` 会停在选择面板,可选择空文件系统、内置 fixtures,或归另一实现所有的 WebFS provider。合法的 `preview-fixture=none|` query 会直接选择并跳过面板,供确定性的浏览器流程使用;该独立名称避开 Client 既有的 `fixture` transport 开关。Worker 先挂载基础镜像,再按顺序把所选 overlays 应用到仅限 `home/` 和 `workspace/` 的路径,随后才校验基础 manifest 并启动 Cordis。`packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` 提供其中一套内置 overlay,Packer 无需理解 Session 或 Workspace。明文 JSONL 日志使用 persistence backend 的真实 project/session 目录布局,因此 Session Persistence 会冷读取它们,Workspace Registry 则根据其 `/dsh/workspace` header 派生 Workspace。主 Session 超过 Client 的 50-message page,并把代表性工具结果留在尾页;持久化的 one-shot 与 continuable child 用于验证 subagent catalog。WebFS 授权与用户数据仍属于独立 provider,绝不与该 fixture 共用目录。 + 两个包以 `@deepseek-ai/dsh-experimental-*` 名义放在 `packages/experimental/`,私有且在官方发布之外。承载产品承诺的边界仍在产品包里:注入表、`__DSH_TRANSPORT__` 与 `/plugins` bundle 字节由 `dsh-host-webserver`、`dsh-client-modules`、`dsh-client-connection` 拥有。 ## 曾考虑的替代方案 @@ -26,6 +28,12 @@ **用顶层 await 顺序而非 deferred 去闸标准入口。** 兄弟 module script 互不等待对方的顶层 await;`??=` 安装的 deferred 使握手与求值顺序无关,且失败的握手能 reject 进 boot 页的失败呈现。 +**在 Worker 启动时生成示例状态。** Preview 专用的 Session 或 Workspace 创建分支会绕过冷 persistence 读取,还会让 runtime 拥有测试数据。静态镜像文件与既有用户数据经过相同的发现和分页路径。 + +**通过 WebFS 注入示例。** WebFS 拥有用户选定的 durable storage 及其生命周期。让内置演示依赖它,会使静态 preview 受浏览器持久化状态影响,并模糊哪些字节来自部署。 + +**每套 fixture 各打一份完整基础镜像。** 完整镜像变体会重复 runtime package closure,并使组合数量平方增长。受限 overlay 只保留一个不可变基础镜像,选择面板可组合零到多个数据源,未来 WebFS 水合也能复用同一个 pre-boot 应用点。 + ## 后果 - `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。 @@ -33,3 +41,4 @@ - 转换 corpus 会先通过 Node 导入每个已构建 bundle,再比较 lowered export。固定豁免会点名真正不可导入的 bundle,并在其恢复可导入时失败:`win32-process` 是 Koffi 类型 owner 并承担重复类型豁免;`sandbox-windows-acl` 可正常导入,不承担该豁免。 - served 的 `` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。 - 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。 +- Preview 会停在 pre-boot 来源选择面板。内置示例提供可复现的 Workspace 与冷 Session 语料,无凭据、零模型调用即可检查工具卡、subagent 导航和向前分页;空白选项保留首次启动覆盖。Fixture 测试通过生产 reader 校验物理日志,浏览器验收同时验证两种选择。 diff --git a/packages/experimental/webworker-packer/README.i18n.yaml b/packages/experimental/webworker-packer/README.i18n.yaml index 040b58adda..5e96d5f120 100644 --- a/packages/experimental/webworker-packer/README.i18n.yaml +++ b/packages/experimental/webworker-packer/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/experimental/webworker-packer/README.md -README.md: a04f7579c8b3ddc7e94d2f8ed21251ed3efae302 -README.zh.md: 7876212ffb6ded5c45659502306758d8dd316f67 +README.md: 39d084bc631db387a3b6e526a3a374bc9f766577 +README.zh.md: 11aa04f3db36c09525bc4d4945f77e278602cc0f diff --git a/packages/experimental/webworker-packer/README.md b/packages/experimental/webworker-packer/README.md index a04f7579c8..39d084bc63 100644 --- a/packages/experimental/webworker-packer/README.md +++ b/packages/experimental/webworker-packer/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The VFS image packer: turns one composed profile into the single gzip-compressed tar the browser worker inflates and mounts as its filesystem ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). Nothing is compiled from source — the image carries the repository's real build products, so a preview deployment debugs exactly what the served deployment ships. +The VFS image packer: turns one composed profile into the gzip-compressed base tar the browser worker mounts as its filesystem, and opaque data trees into ordered overlay tars ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). Nothing is compiled from source — the base image carries the repository's real build products, so a preview deployment debugs exactly what the served deployment ships. The pack is a three-layer standard stack: @@ -12,6 +12,8 @@ The pack is a three-layer standard stack: `repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/landlock-run/packages/`, and `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build. +The repository adapter also declares the preview-only fixture trees under `webworker-runtime/tests/fixtures/`. The CLI packs each named fixture into a separate deterministic overlay archive plus a browser-readable manifest. Overlay files bypass npm publish-view and module-reachability exclusions, so dot directories and example source files remain intact; their mounts are limited to `home/` and `workspace/`. `pack.ts` treats them as opaque bytes, and Session and Workspace interpretation stays in the runtime packages that own those formats. + ## Model Experience None, as this package runs at build time and writes an image file; nothing it produces reaches a model request on its own. diff --git a/packages/experimental/webworker-packer/README.zh.md b/packages/experimental/webworker-packer/README.zh.md index 7876212ffb..11aa04f3db 100644 --- a/packages/experimental/webworker-packer/README.zh.md +++ b/packages/experimental/webworker-packer/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后当文件系统挂载的单个 gzip 压缩 tar([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。不做任何源码编译——镜像携带仓库真实构建产物,预览部署调试的正是 served 部署交付的字节。 +VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为文件系统的 gzip 压缩基础 tar,并把不透明数据目录变成按序应用的 overlay tar([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。不做任何源码编译——基础镜像携带仓库真实构建产物,预览部署调试的正是 served 部署交付的字节。 打包是三层标准栈: @@ -12,6 +12,8 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后 `repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/landlock-run/packages/` 与 `apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。 +仓库适配层还声明 `webworker-runtime/tests/fixtures/` 下仅用于 preview 的 fixture tree。CLI 会把每套具名 fixture 打成一份独立的确定性 overlay 归档,并写出浏览器可读的 manifest。Overlay 文件绕过 NPM 发布视图和模块可达性排除规则,因此点目录与示例源码会完整保留;其挂载位置仅限 `home/` 与 `workspace/`。`pack.ts` 把它们视为不透明字节;Session 与 Workspace 的解释仍归拥有这些格式的 runtime 包。 + ## 模型体验 无:本包在构建期运行并写出镜像文件,其产物本身不进入任何模型请求。 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 6dc2eb15e7..f5d10e0a50 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: 88d4213b5eb2f82cc41ad5059d9473d4a8abf53b -README.zh.md: 6f4fd2612d5daa28831890ff64171ad780274958 +README.md: 5b63856b826d4f8bc8b6ff56626c6e7a1ed663b1 +README.zh.md: 59b31bd308077b8eeaa60edf5bd23a75924eaa07 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 88d4213b5e..5b63856b82 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -6,12 +6,12 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: -- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the image (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. +- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — `connectWorkerHost(worker, { image? })` completes the pre-Cordis handshake: the opening `init` frame carries the image URL (the one deployment-shaped input), the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. -Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the worker boot in headless Chromium. +Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. ## Model Experience diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 6f4fd2612d..59b31bd308 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -6,12 +6,12 @@ 一条 tsdown 管线出三个产物: -- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载镜像(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 +- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——`connectWorkerHost(worker, { image? })` 完成 pre-Cordis 握手:开局 `init` 帧携带镜像 URL(唯一部署形态输入),boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 -验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 worker 启动。 +验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 ## 模型体验 From e883dc235474ff37fc49bad99ee6802ee68cb933 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:53:28 +0800 Subject: [PATCH 019/138] feat(webworker): add selectable preview fixtures --- apps/web/src/preview.ts | 16 +- apps/web/tests/preview-boot.e2e.ts | 235 ++++++++-- .../preview-boot/source-chooser.expected.md | 15 + knip.json | 3 +- .../webworker-packer/package.json | 2 +- .../experimental/webworker-packer/src/bin.ts | 44 +- .../webworker-packer/src/index.ts | 7 +- .../experimental/webworker-packer/src/pack.ts | 66 ++- .../webworker-packer/src/repository.ts | 34 +- .../tests/image-loadable.spec.ts | 30 +- .../webworker-runtime/package.json | 3 + .../webworker-runtime/src/client/client.ts | 5 +- .../webworker-runtime/src/client/index.ts | 76 ++- .../src/client/source-chooser.ts | 178 ++++++++ .../webworker-runtime/src/fixture-manifest.ts | 67 +++ .../webworker-runtime/src/image-layout.ts | 12 +- .../webworker-runtime/src/index.ts | 8 +- .../builtin_modules/implemented/fs-watch.ts | 1 + .../builtin_modules/implemented/stream.ts | 2 + .../webworker-runtime/src/shell/fs-access.ts | 18 +- .../webworker-runtime/src/storage/memory.ts | 36 ++ .../webworker-runtime/src/transport/frames.ts | 10 +- .../webworker-runtime/src/worker-host.ts | 12 +- .../webworker-runtime/src/worker.ts | 10 +- .../tests/client/source-chooser.spec.ts | 150 ++++++ .../tests/fixture-manifest.spec.ts | 48 ++ .../preview-architecture-review/session.jsonl | 178 ++++++++ .../preview-follow-up-builder/session.jsonl | 8 + .../preview-showcase/session.jsonl | 200 ++++++++ .../home/storages/session_projcache.json | 24 + .../.agents/skills/preview-tour/SKILL.md | 8 + .../fixtures/vfs-example/workspace/PREVIEW.md | 9 + .../vfs-example/workspace/data/tasks.json | 17 + .../vfs-example/workspace/src/preview.ts | 3 + .../tests/node/child-process.spec.ts | 7 +- .../tests/storage/tar.spec.ts | 19 +- .../tests/transport/frames.spec.ts | 22 + .../tests/transport/tunnel-client.spec.ts | 21 + .../tests/vfs-example-fixture.spec.ts | 118 +++++ .../tests/vfs-example-fixture.ts | 432 ++++++++++++++++++ pnpm-lock.yaml | 9 + scripts/session-fixture-layout.spec.ts | 14 +- scripts/session-fixture-layout.ts | 15 + 43 files changed, 2084 insertions(+), 108 deletions(-) create mode 100644 apps/web/tests/snapshots/preview-boot/source-chooser.expected.md create mode 100644 packages/experimental/webworker-runtime/src/client/source-chooser.ts create mode 100644 packages/experimental/webworker-runtime/src/fixture-manifest.ts create mode 100644 packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/fixture-manifest.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-showcase/session.jsonl create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/.agents/skills/preview-tour/SKILL.md create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/PREVIEW.md create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/data/tasks.json create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/src/preview.ts create mode 100644 packages/experimental/webworker-runtime/tests/transport/frames.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts diff --git a/apps/web/src/preview.ts b/apps/web/src/preview.ts index 586cbcab5d..f3d51f8bbc 100644 --- a/apps/web/src/preview.ts +++ b/apps/web/src/preview.ts @@ -1,12 +1,14 @@ /** * Worker-preview bootstrap: the one module preview.html adds ahead of the - * stock entry tag. Connecting the worker host installs the boot globals and - * settles `__DSH_BOOT_READY__`, where the stock entry's pre-boot await holds, - * so everything after this module is the served startup chain verbatim. A - * failed handshake rejects the deferred into the boot page's failure - * rendering; this module owns no page painting. + * stock entry tag. The runtime's optional source stage owns the pre-Cordis + * chooser; the unchanged Host connector then owns the Worker handshake. + * Everything after those calls is the served startup chain verbatim. */ import DshWorker from '@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker' -import { connectWorkerHost, IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime/client' +import { + chooseWorkerHostSource, connectWorkerHost, IMAGE_FILE_NAME, +} from '@deepseek-ai/dsh-experimental-webworker-runtime/client' -await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image: `preview/${IMAGE_FILE_NAME}` }) +const image = `preview/${IMAGE_FILE_NAME}` +const source = await chooseWorkerHostSource({ image }) +await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image, overlays: source.overlays }) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 1b8bc85f32..b4675926bd 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -8,27 +8,33 @@ * Two milestones prove that happened — the host's `tree active` boot line, * whose lowering contract must be the one this checkout's packer emits, and the * workspace hero, which paints only after the client tree comes up over the - * tunnel. The same page then creates a Workspace and Session, lists skills, - * and writes through the settings and credentials providers, exercising the - * upstream Chokidar instances over the Worker filesystem implementation. + * tunnel. The same page opens the seeded Workspace and showcase Session, + * verifies its tool/subagent/history examples, then writes through the + * settings and credentials providers. That keeps the upstream Chokidar + * instances exercised over the Worker filesystem implementation. * * The site is served the way a static host serves it: bytes from `dist/` with * no rewrite rules, so a missing file is a 404 rather than the index page. */ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { tmpdir } from 'node:os' -import { extname, join, normalize } from 'node:path' +import { dirname, extname, join, normalize } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' import type { Browser } from 'playwright' import { expect, it } from 'vitest' import { - composeProfile, configTrees, indexWorkspacePackages, packVfsImage, WRAPPER_CONTRACT, + composeProfile, configTrees, indexWorkspacePackages, packVfsImage, packVfsOverlay, + previewFixtures, WRAPPER_CONTRACT, } from '@deepseek-ai/dsh-experimental-webworker-packer' -import { IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime' +import { + IMAGE_FILE_NAME, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, + type PreviewFixtureManifest, +} from '@deepseek-ai/dsh-experimental-webworker-runtime' +import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) @@ -36,9 +42,22 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) /** Where the client looks for the image: the runtime's own name, beside the page. */ const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME) +/** Built-in source catalog read by the pre-boot chooser. */ +const FIXTURE_MANIFEST_FILE = join(DIST_ROOT, 'preview', PREVIEW_FIXTURE_MANIFEST_FILE) + +/** Keyless browser golden for the pre-Worker source chooser. */ +const SOURCE_CHOOSER_EXPECTED = fileURLToPath(new URL('./snapshots/preview-boot/source-chooser.expected.md', import.meta.url)) + +const SNAPSHOT_MODE = webSnapshotMode() + /** Profile the preview deployment composes; `build:preview` packs the same one. */ const PROFILE = 'web' +/** Stable labels authored by the deterministic VFS example fixture. */ +const SHOWCASE_TITLE = 'WebWorker Preview Showcase' +const SHOWCASE_TAIL = 'Preview tour complete' +const SHOWCASE_OLDEST = 'History checkpoint 01: verify deterministic preview state.' + /** Pages the preview needs; the Vite build emits both. */ const PAGES = ['index.html', 'preview.html'] @@ -77,6 +96,12 @@ interface Site { close(): Promise } +interface PreviewAssets { + /** Static-host-relative path to a generated file outside `dist/`. */ + readonly overrides: ReadonlyMap + cleanup(): void +} + /** * Fail before the browser opens a page the build never produced. * @throws When either preview page is missing from `dist/`. @@ -89,20 +114,25 @@ function requirePreviewPages(): void { } /** - * The image file to serve, packed here when `dist/` carries none: `pnpm run - * build` emits the pages but only `build:preview` packs, so this lane packs - * for itself rather than skipping the deployment it is here to accept. An - * image already in place is used as it stands — the worker refuses one lowered - * against another wrapper contract, and that refusal names the rebuild. A - * self-packed image lands in a temp directory, never in `dist/`: the + * The base image, fixture manifest, and overlays to serve, packed here when + * `dist/` does not carry the complete set: `pnpm run build` emits the pages but + * only `build:preview` packs these files, so this lane packs for itself rather + * than skipping the deployment it accepts. A complete built set is used as it + * stands — the worker refuses a base lowered against another wrapper contract. + * Self-packed files land in a temp directory, never in `dist/`: the * client-artifact digest record treats `dist/` as build-owned, so a test write * there fails the record check for every later consumer. - * @returns The file to answer `preview/` with, and its teardown. + * @returns Static-path overrides and their teardown. * @throws When the closure leaves dependencies unresolved, which would pack an * incomplete image the tree fails on later and further from the cause. */ -function requireVfsImage(): { path: string; cleanup(): void } { - if (existsSync(IMAGE_FILE)) return { path: IMAGE_FILE, cleanup: () => {} } +function requireVfsAssets(): PreviewAssets { + const fixtureDefinitions = previewFixtures(REPO_ROOT) + const fixtureFiles = fixtureDefinitions.map(fixture => + join(DIST_ROOT, 'preview', 'fixtures', `${fixture.id}.tar.gz`)) + if ([IMAGE_FILE, FIXTURE_MANIFEST_FILE, ...fixtureFiles].every(existsSync)) { + return { overrides: new Map(), cleanup: () => {} } + } const packed = packVfsImage({ config: composeProfile(REPO_ROOT, PROFILE), profile: PROFILE, @@ -114,23 +144,48 @@ function requireVfsImage(): { path: string; cleanup(): void } { throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`) } const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-')) - const path = join(directory, IMAGE_FILE_NAME) - writeFileSync(path, packed.image) - return { path, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } } + const overrides = new Map() + const writeAsset = (relativePath: string, bytes: Uint8Array | string): void => { + const path = join(directory, relativePath) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, bytes) + overrides.set(relativePath, path) + } + writeAsset(`preview/${IMAGE_FILE_NAME}`, packed.image) + const fixtures = fixtureDefinitions.map((fixture) => { + const relativePath = `preview/fixtures/${fixture.id}.tar.gz` + writeAsset(relativePath, packVfsOverlay(fixture.trees).image) + return { + id: fixture.id, + label: fixture.label, + description: fixture.description, + overlays: [`fixtures/${fixture.id}.tar.gz`], + } + }) + const manifest: PreviewFixtureManifest = { + version: PREVIEW_FIXTURE_MANIFEST_VERSION, + defaultFixture: fixtures[0]?.id ?? null, + fixtures, + } + writeAsset(`preview/${PREVIEW_FIXTURE_MANIFEST_FILE}`, `${JSON.stringify(manifest, null, 2)}\n`) + return { overrides, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } } } /** - * Answer one request with the file it names under `dist/`; the image path - * answers from wherever {@link requireVfsImage} put the file. + * Answer one request with its generated override or the file under `dist/`. * @param request - Incoming request; only its path is read. * @param response - Response to write the bytes or the 404 to. - * @param imagePath - File behind `preview/`. + * @param overrides - Generated deployment files used when `dist/` has none. */ -async function respond(request: IncomingMessage, response: ServerResponse, imagePath: string): Promise { +async function respond( + request: IncomingMessage, + response: ServerResponse, + overrides: ReadonlyMap, +): Promise { const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '') try { - const body = await readFile(relative === `preview/${IMAGE_FILE_NAME}` ? imagePath : join(DIST_ROOT, relative)) + const body = await readFile(overrides.get(relative) ?? join(DIST_ROOT, relative)) response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' }) response.end(body) } catch { @@ -144,11 +199,11 @@ async function respond(request: IncomingMessage, response: ServerResponse, image /** * Serve `dist/` over loopback with static-host semantics. - * @param imagePath - File behind `preview/`. + * @param overrides - Generated deployment files used when `dist/` has none. * @returns The origin to navigate, and its teardown. */ -async function serveDist(imagePath: string): Promise { - const server = createServer((request, response) => { void respond(request, response, imagePath) }) +async function serveDist(overrides: ReadonlyMap): Promise { + const server = createServer((request, response) => { void respond(request, response, overrides) }) await new Promise((listening) => { server.listen(0, '127.0.0.1', listening) }) const address = server.address() if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port') @@ -188,12 +243,13 @@ async function within(work: Promise, ms: number, stalled: string): Promise it('boots the packed worker deployment to an interactive page', async () => { requirePreviewPages() - const image = requireVfsImage() + const assets = requireVfsAssets() try { - const site = await serveDist(image.path) + const site = await serveDist(assets.overrides) try { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] }) try { + await bootEmptyPreview(site.origin, browser) await bootPreview(site.origin, browser) } finally { await browser.close() @@ -202,7 +258,7 @@ it('boots the packed worker deployment to an interactive page', async () => { await site.close() } } finally { - image.cleanup() + assets.cleanup() } }, 600_000) @@ -227,26 +283,34 @@ async function bootPreview(origin: string, browser: Browser): Promise { }) try { await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' }) + await page.getByRole('heading', { name: '选择 Preview 数据源' }).waitFor() + expect(await page.locator('input[name="preview-source"][value="vfs-example"]').isChecked()).toBe(true) + expect(await page.getByText('空白环境', { exact: true }).count()).toBe(1) + expect(await page.getByText('WebFS 目录', { exact: true }).count()).toBe(1) + expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true) + expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0) + await compareOrRefreshGolden( + SOURCE_CHOOSER_EXPECTED, + await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'), + SNAPSHOT_MODE, + ) + await page.getByRole('button', { name: '启动 Preview' }).click() const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`) // The activated tree ran bodies lowered against the contract this // checkout's packer emits; a dist built before a contract change would // report the older one. expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) + expect(bootLine).toContain('data overlays=1') // The hero's workspace picker is the client tree's first interactive // surface, so it appears only once the startup chain completed over the // tunnel. await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) const continueButton = page.getByRole('button', { name: 'Continue' }) - if (await continueButton.isVisible()) await continueButton.click() - await page.getByRole('button', { name: 'Configure later' }).click() - await page.getByRole('textbox', { name: 'Choose workspace' }).click() - const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: 'Edit path' }).click() - const pathInput = dialog.getByRole('textbox', { name: 'Edit path' }) - await pathInput.fill('/dsh/workspace') - await pathInput.press('Enter') - await dialog.getByRole('button', { name: 'Open', exact: true }).click() + await continueButton.waitFor({ timeout: 30_000 }) + await continueButton.click() + const configureLater = page.getByRole('button', { name: 'Configure later' }) + await configureLater.waitFor({ timeout: 30_000 }) + await configureLater.click() await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') .waitFor({ timeout: 30_000 }) @@ -296,9 +360,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { const refreshed = await api.skills.list({ sessionId }) if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) } - await createDirectory('/dsh/workspace', '.agents') - await createDirectory('/dsh/workspace/.agents', 'skills') - await createDirectory('/dsh/workspace/.agents/skills', 'placeholder') + await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created') const settings = await api.settings.describe({}) if (!settings.result.ok) throw new Error(`settings.describe failed: ${settings.result.error.message}`) const shell = settings.result.value.namespaces.find(namespace => namespace.ns === 'shell') @@ -317,8 +379,31 @@ async function bootPreview(origin: string, browser: Browser): Promise { credentialConfigured: credentials.result.value.credentials.PREVIEW_TEST_SECRET?.configured, } }) - expect(exercised.skillCount).toBeGreaterThanOrEqual(0) + expect(exercised.skillCount).toBeGreaterThan(0) expect(exercised.credentialConfigured).toBe(true) + + const sessions = page.getByRole('tree', { name: 'Sessions' }) + const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE }) + await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1) + await showcase.click() + await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 }) + + expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0) + await page.getByText('PREVIEW.md', { exact: true }).waitFor() + await page.getByText('src/preview.ts', { exact: true }).waitFor() + await page.getByText('Update to-do list', { exact: true }).waitFor() + await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor() + + const subagents = page.getByRole('button', { name: '2 subagents' }) + await subagents.waitFor({ timeout: 15_000 }) + await subagents.hover() + const catalog = page.getByRole('tree', { name: 'Subagent sessions' }) + await catalog.getByRole('treeitem', { name: /Review preview architecture/ }).waitFor() + await catalog.getByRole('treeitem', { name: /Continue preview verification/ }).waitFor() + await catalog.press('Escape') + + await page.getByRole('button', { name: 'Load earlier', exact: true }).click() + await page.getByText(SHOWCASE_OLDEST, { exact: true }).waitFor({ timeout: 15_000 }) expect(pageErrors.map(error => error.message)).toEqual([]) expect(consoleErrors.filter(line => /watchFile|failed to watch|node-addon-landlock-run\.probe|sandbox backend is usable|SANDBOX_UNAVAILABLE/i.test(line))).toEqual([]) @@ -329,3 +414,65 @@ async function bootPreview(origin: string, browser: Browser): Promise { : new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors') } } + +/** Verify the chooser can boot the untouched base image and reach first-run UI. */ +async function bootEmptyPreview(origin: string, browser: Browser): Promise { + const page = await newEnglishPage(browser) + const pageErrors: Error[] = [] + const consoleErrors: string[] = [] + const failedResponses: string[] = [] + page.on('pageerror', (error) => { pageErrors.push(error) }) + page.on('response', (response) => { + if (response.status() >= 400) failedResponses.push(new URL(response.url()).pathname) + }) + const treeActive = new Promise((reported) => { + page.on('console', (message) => { + const text = message.text() + if (text.includes(TREE_ACTIVE)) reported(text) + if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(text) + }) + }) + try { + await page.goto(`${origin}/preview.html?preview-fixture=none`, { waitUntil: 'domcontentloaded' }) + expect(await page.getByRole('heading', { name: '选择 Preview 数据源' }).count()).toBe(0) + const bootLine = await within( + treeActive, + BOOT_TIMEOUT_MS, + `empty preview boot: the worker never reported "${TREE_ACTIVE}"`, + ) + expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) + expect(bootLine).toContain('data overlays=0') + await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + const sessionCount = await page.evaluate(async () => { + const transport = (globalThis as typeof globalThis & { + __DSH_TRANSPORT__?: { fetch(input: string, init: RequestInit): Promise } + }).__DSH_TRANSPORT__ + if (transport === undefined) throw new Error('empty preview transport is absent after boot') + const response = await transport.fetch('/api/session/list', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'empty-preview-session-list', method: 'session/list', + payload: { args: { _request: {} } }, + }), + }) + const body = await response.json() as { + result: { ok: true; value: { items: unknown[] } } | { ok: false; error: { message: string } } + } + if (!body.result.ok) throw new Error(`empty session/list failed: ${body.result.error.message}`) + return body.result.value.items.length + }) + expect(sessionCount).toBe(0) + expect(pageErrors.map(error => error.message)).toEqual([]) + expect(failedResponses).toEqual(['/plugins/events']) + expect(consoleErrors.filter(line => !line.includes('Failed to load resource: the server responded with a status of 404'))) + .toEqual([]) + } catch (error) { + await saveFailureShot(page, 'preview-boot-empty') + throw pageErrors.length === 0 + ? error + : new AggregateError([error, ...pageErrors], 'empty preview boot failed, with uncaught page errors') + } finally { + await page.close() + } +} diff --git a/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md b/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md new file mode 100644 index 0000000000..18dd2a442b --- /dev/null +++ b/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md @@ -0,0 +1,15 @@ +- form "选择 Preview 数据源": + - heading "选择 Preview 数据源" [level=1] + - paragraph: 数据会在 Worker 和应用启动前挂载;刷新页面可重新选择。 + - group "文件系统来源": + - text: 文件系统来源 + - radio "空白环境 只加载基础运行时,用于验证首次启动与新建 Workspace。" + - strong: 空白环境 + - text: 只加载基础运行时,用于验证首次启动与新建 Workspace。 + - radio "内置综合示例 示例 Workspace、工具卡、子代理与分页会话。" [checked] + - strong: 内置综合示例 + - text: 示例 Workspace、工具卡、子代理与分页会话。 + - radio "WebFS 目录 需要用户授权的目录来源,将在 WebFS provider 接入后开放。" [disabled] + - strong: WebFS 目录 + - text: 需要用户授权的目录来源,将在 WebFS provider 接入后开放。 + - button "启动 Preview" diff --git a/knip.json b/knip.json index b457dd54f3..b6c4e33254 100644 --- a/knip.json +++ b/knip.json @@ -241,7 +241,8 @@ "packages/experimental/webworker-runtime": { "entry": [ "tests/**/*.spec.ts", - "tests/compile/transform-corpus-check.ts" + "tests/compile/transform-corpus-check.ts", + "tests/fixtures/vfs-example/workspace/src/preview.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index e9219b35f9..a8155676dd 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", - "description": "Build-time packer for the browser runtime's VFS image: materializes a profile's package closure into one gzip-compressed tar the worker mounts, with every module body pre-transformed", + "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", "version": "0.1.1-rc.2", "private": true, "repository": { diff --git a/packages/experimental/webworker-packer/src/bin.ts b/packages/experimental/webworker-packer/src/bin.ts index 57a3c77730..093fac23d5 100644 --- a/packages/experimental/webworker-packer/src/bin.ts +++ b/packages/experimental/webworker-packer/src/bin.ts @@ -1,17 +1,23 @@ #!/usr/bin/env node /** - * Pack a VFS image from this repository: compose the profile, materialize the - * closure, lower every module body, write the gzip-compressed tar. + * Pack a Preview deployment from this repository: compose and lower the base + * image, then write each named fixture overlay and their manifest. * * Usage: dsh-pack-vfs-image --out [--profile web] [--root /dsh] * node --import tsx/esm src/bin.ts --out ../../apps/web/dist/preview/vfs-image.tar.gz * @module @deepseek-ai/dsh-experimental-webworker-packer/src/bin */ import { mkdirSync, writeFileSync } from 'node:fs' -import { dirname, isAbsolute, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { packVfsImage } from './pack.ts' -import { composeProfile, configTrees, describePack, indexWorkspacePackages } from './repository.ts' +import { + PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, + type PreviewFixtureManifest, +} from '@deepseek-ai/dsh-experimental-webworker-runtime' +import { packVfsImage, packVfsOverlay } from './pack.ts' +import { + composeProfile, configTrees, describePack, indexWorkspacePackages, previewFixtures, +} from './repository.ts' /** * Read one `--flag value` pair. @@ -54,4 +60,30 @@ if (result.missing.length > 0) { mkdirSync(dirname(outputFile), { recursive: true }) writeFileSync(outputFile, result.image) -process.stdout.write(describePack(result, repoRoot, outputFile).join('\n')) + +const fixtureDefinitions = previewFixtures(repoRoot) +const fixtureDirectory = join(dirname(outputFile), 'fixtures') +mkdirSync(fixtureDirectory, { recursive: true }) +const fixtureLines: string[] = [] +const fixtures = fixtureDefinitions.map((fixture) => { + const packed = packVfsOverlay(fixture.trees) + const file = `fixtures/${fixture.id}.tar.gz` + writeFileSync(join(dirname(outputFile), file), packed.image) + fixtureLines.push(` fixture overlay ${fixture.id} (${String(packed.image.byteLength)} B compressed)`) + return { + id: fixture.id, + label: fixture.label, + description: fixture.description, + overlays: [file], + } +}) +const manifest: PreviewFixtureManifest = { + version: PREVIEW_FIXTURE_MANIFEST_VERSION, + defaultFixture: fixtures[0]?.id ?? null, + fixtures, +} +writeFileSync( + join(dirname(outputFile), PREVIEW_FIXTURE_MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n`, +) +process.stdout.write([...describePack(result, repoRoot, outputFile), ...fixtureLines, ''].join('\n')) diff --git a/packages/experimental/webworker-packer/src/index.ts b/packages/experimental/webworker-packer/src/index.ts index ea054b26b0..ea203a6f7a 100644 --- a/packages/experimental/webworker-packer/src/index.ts +++ b/packages/experimental/webworker-packer/src/index.ts @@ -7,9 +7,10 @@ export { type ImageFiles, type TransformOutcome, } from './transform-image.ts' export { - CONFIG_PATH, DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, - type ConfigTree, type PackOptions, type PackResult, + CONFIG_PATH, DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, packVfsOverlay, + type ConfigTree, type ImageTree, type PackOptions, type PackOverlayResult, type PackResult, } from './pack.ts' export { - composeProfile, configTrees, describePack, indexWorkspacePackages, + composeProfile, configTrees, describePack, indexWorkspacePackages, previewFixtures, + type PreviewFixture, } from './repository.ts' diff --git a/packages/experimental/webworker-packer/src/pack.ts b/packages/experimental/webworker-packer/src/pack.ts index a5dcb00156..3e86610233 100644 --- a/packages/experimental/webworker-packer/src/pack.ts +++ b/packages/experimental/webworker-packer/src/pack.ts @@ -19,6 +19,7 @@ import { gzipSync } from 'node:zlib' import { lowerModuleSource, MemoryVfs, packTar, WorkerModuleLoader, DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_MANIFEST_PATH, + IMAGE_OVERLAY_DIRECTORIES, } from '@deepseek-ai/dsh-experimental-webworker-runtime' import picomatch from 'picomatch' import yaml from 'js-yaml' @@ -52,12 +53,16 @@ const workspaceExcluded = picomatch([...EXCLUDE, ...EXCLUDE_WORKSPACE], { dot: t /** Page-asset matcher over image paths ({@link PAGE_ASSETS}). */ const pageAsset = picomatch([...PAGE_ASSETS], { dot: true }) -/** One directory tree to copy in verbatim beside the composition. */ -export interface ConfigTree { +/** One directory tree to copy into the image at a caller-selected mount. */ +export interface ImageTree { /** Image path to mount it at, relative to the virtual root. */ readonly mount: string /** Absolute source directory. */ readonly directory: string +} + +/** One configuration tree whose plugin rows may extend the package roster. */ +export interface ConfigTree extends ImageTree { /** * Whether plugin names inside its `.yml` files join the materialization closure. * An agent preset mounts plugins the base composition never lists, and creating a @@ -119,6 +124,14 @@ export interface PackResult { readonly contract: string } +/** One deterministic data-overlay archive and its uncompressed entries. */ +export interface PackOverlayResult { + /** Gzip-compressed ustar bytes consumed by the Worker host. */ + readonly image: Uint8Array + /** Every path in the overlay before compression. */ + readonly files: ImageFiles +} + const readJson = (file: string): Record => JSON.parse(readFileSync(file, 'utf8')) as Record @@ -204,20 +217,28 @@ function resolveDependency(fromDirectory: string, name: string): string | undefi /** * Collect files under one directory. Traversal mechanics live here — nested - * `node_modules` never mounts (the image is flat) and dot directories are - * tooling residue at any depth — while every judgement call comes in through - * `keep` (the {@link EXCLUDE} tables and the npm publish view). + * package/config collection flattens nested `node_modules` and prunes dot + * directories, while seed collection preserves every directory. Every file + * judgement comes in through `keep` (the {@link EXCLUDE} tables and the npm + * publish view, or an unconditional seed predicate). * @param root - Source directory. * @param into - Image entries to add to. * @param prefix - Image path prefix. * @param keep - Filter over root-relative paths. + * @param preserveDirectories - Whether dot directories and nested `node_modules` + * are ordinary fixture content rather than package-manager residue. */ -function collectTree(root: string, into: ImageFiles, prefix: string, keep: (relativePath: string) => boolean): void { +function collectTree( + root: string, + into: ImageFiles, + prefix: string, + keep: (relativePath: string) => boolean, + preserveDirectories = false, +): void { const walk = (directory: string): void => { for (const entry of readdirSync(directory, { withFileTypes: true })) { if (entry.isDirectory()) { - if (entry.name === 'node_modules') continue - if (entry.name.startsWith('.')) continue + if (!preserveDirectories && (entry.name === 'node_modules' || entry.name.startsWith('.'))) continue walk(join(directory, entry.name)) continue } @@ -621,3 +642,32 @@ export function packVfsImage(options: PackOptions): PackResult { contract: WRAPPER_CONTRACT, } } + +/** + * Pack opaque data trees into one ordered VFS overlay. + * + * Overlay mounts are restricted to the runtime-owned data directories, so an + * overlay cannot replace configuration, the lowering manifest, or modules. + * Files bypass package excludes and module reachability processing; later + * trees replace earlier files at the same path. + * @param trees - Absolute source directories and their data-directory mounts. + * @returns Deterministic compressed archive plus its uncompressed entries. + */ +export function packVfsOverlay(trees: readonly ImageTree[]): PackOverlayResult { + const files: ImageFiles = {} + for (const tree of trees) { + if (!existsSync(tree.directory)) { + throw new Error(`vfs overlay: tree ${tree.mount} is missing at ${tree.directory}`) + } + const mount = tree.mount.replace(/^\.\//, '').replace(/\/$/, '') + const first = mount.split('/')[0] + if (mount === '' || first === undefined || !IMAGE_OVERLAY_DIRECTORIES.includes(first) + || mount.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) { + throw new Error( + `vfs overlay: mount ${JSON.stringify(tree.mount)} must stay under ${IMAGE_OVERLAY_DIRECTORIES.join(' or ')}`, + ) + } + collectTree(tree.directory, files, mount, () => true, true) + } + return { image: compressImage(packTar(files)), files } +} diff --git a/packages/experimental/webworker-packer/src/repository.ts b/packages/experimental/webworker-packer/src/repository.ts index 6ae2e59bee..5e433ea425 100644 --- a/packages/experimental/webworker-packer/src/repository.ts +++ b/packages/experimental/webworker-packer/src/repository.ts @@ -12,7 +12,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { DSH_HOME_ENV } from '@deepseek-ai/dsh-home-paths' -import type { ConfigTree, PackResult } from './pack.ts' +import type { ConfigTree, ImageTree, PackResult } from './pack.ts' /** * Repository directories scanned for workspace and vendored packages. The @@ -28,6 +28,21 @@ const CLI_PACKAGE = 'apps/cli' /** Composition entry point: the `dsh` CLI, run from source. */ const CLI_ENTRY = `${CLI_PACKAGE}/src/bin.ts` +/** Repository-owned deterministic filesystem content offered by the preview. */ +const PREVIEW_EXAMPLE_ROOT = 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example' + +/** One built-in Preview source and the trees packed into its overlay. */ +export interface PreviewFixture { + /** URL/query-safe identifier. */ + readonly id: string + /** User-facing chooser label. */ + readonly label: string + /** User-facing chooser detail. */ + readonly description: string + /** Opaque trees packed into this fixture's overlay archive. */ + readonly trees: readonly ImageTree[] +} + /** * Index every workspace and vendored package by name. * @param repoRoot - Absolute repository root. @@ -130,6 +145,23 @@ export function configTrees(repoRoot: string): ConfigTree[] { }) } +/** + * Built-in filesystem fixtures offered by the repository preview. + * Session and Workspace semantics remain opaque here; the owning runtime tests + * validate those files through their production readers. + * @param repoRoot - Absolute repository root. + * @returns Named chooser entries and their overlay trees. + */ +export function previewFixtures(repoRoot: string): PreviewFixture[] { + const root = join(repoRoot, PREVIEW_EXAMPLE_ROOT) + return [{ + id: 'vfs-example', + label: '内置综合示例', + description: '示例 Workspace、工具卡、子代理与分页会话。', + trees: ['home', 'workspace'].map(mount => ({ mount, directory: join(root, mount) })), + }] +} + /** * Render one pack as the lines a build log should carry. * diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index e75b4f523b..609e3ccf80 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -26,8 +26,8 @@ import { } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts' import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' -import { indexWorkspacePackages } from '../src/repository.ts' -import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage } from '../src/pack.ts' +import { indexWorkspacePackages, previewFixtures } from '../src/repository.ts' +import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, packVfsOverlay } from '../src/pack.ts' const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) @@ -37,6 +37,32 @@ const LANDLOCK = '@deepseek-ai/node-addon-landlock-run' const workspaces = indexWorkspacePackages(repoRoot) +describe('preview example overlays', () => { + it('packs source-looking paths and dot directories into a separate overlay', () => { + const fixture = previewFixtures(repoRoot)[0] + expect(fixture?.id).toBe('vfs-example') + const result = packVfsOverlay(fixture?.trees ?? []) + expect(new TextDecoder().decode(result.files['workspace/src/preview.ts'])) + .toContain("previewStatus = 'ready'") + expect(new TextDecoder().decode(result.files['workspace/.agents/skills/preview-tour/SKILL.md'])) + .toContain('name: preview-tour') + expect(Object.keys(result.files).filter(path => path.endsWith('/session.jsonl'))).toHaveLength(3) + }) + + it('fails loud when a declared seed tree is absent', () => { + expect(() => packVfsOverlay([ + { mount: 'workspace', directory: join(repoRoot, 'missing-preview-seed') }, + ])).toThrow(/tree workspace is missing/) + }) + + it('refuses overlays that could replace runtime files', () => { + const fixture = previewFixtures(repoRoot)[0] + expect(() => packVfsOverlay([ + { mount: 'config', directory: fixture?.trees[0]?.directory ?? repoRoot }, + ])).toThrow(/must stay under home or workspace/) + }) +}) + /** * The pack consumes built `lib/` output. An unbuilt checkout (the unit * coverage lane runs before any build) self-skips; the built lanes and every diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 422bd4f5ec..68643425f8 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -57,6 +57,9 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:^", "@types/picomatch": "^3.0.2", diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 7fa05a5355..0300347297 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -156,9 +156,10 @@ export class WorkerTunnel { /** * Open the tunnel: the worker assembles its host from this frame. * @param image - VFS image URL the worker fetches. + * @param overlays - Ordered data overlay URLs applied before boot. */ - init(image: string): void { - this.worker.postMessage({ t: 'init', image }) + init(image: string, overlays: readonly string[] = []): void { + this.worker.postMessage({ t: 'init', image, overlays }) } /** Fetch-shaped entry: one request frame, one Response (streamed when the worker streams). */ diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index c84f637896..8494e1c06a 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -9,14 +9,20 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/client */ import { IMAGE_FILE_NAME } from '../image-layout.ts' +import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts' import { WorkerApiClient } from './api-client.ts' import { WorkerTunnel, type TunnelFetch } from './client.ts' import { applyIndexInjections } from './apply-injections.ts' +import { choosePreviewSource } from './source-chooser.ts' export { WorkerApiClient } from './api-client.ts' export { WorkerTunnel, type TunnelFetch } from './client.ts' export { applyIndexInjections } from './apply-injections.ts' export { IMAGE_FILE_NAME } from '../image-layout.ts' +export { + parsePreviewFixtureManifest, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, + type PreviewFixtureManifest, type PreviewFixtureManifestEntry, +} from '../fixture-manifest.ts' /** Transport global the connection plugin reads instead of building an HTTP carrier. */ interface ClientTransportGlobal { @@ -35,9 +41,25 @@ export interface WorkerHostConnectOptions { /** * VFS image URL, the one deployment-shaped input. Defaults to * {@link IMAGE_FILE_NAME} beside the page; a deployment that packs the - * image elsewhere passes its own URL. + * image elsewhere passes its own URL. Data overlays are independent. */ readonly image?: string | URL + /** Ordered data overlay URLs, resolved against the page like the base image. */ + readonly overlays?: readonly (string | URL)[] +} + +/** Inputs for the optional pre-boot filesystem-source chooser. */ +export interface WorkerHostSourceOptions { + /** Base VFS image URL; defaults to {@link IMAGE_FILE_NAME} beside the page. */ + readonly image?: string | URL + /** Fixture catalog URL; defaults to {@link PREVIEW_FIXTURE_MANIFEST_FILE} beside the image. */ + readonly fixtureManifest?: string | URL +} + +/** Filesystem inputs selected before {@link connectWorkerHost}. */ +export interface WorkerHostSource { + /** Ordered data overlays to pass through unchanged to the Host connection. */ + readonly overlays: readonly URL[] } /** A page connected to a worker-hosted harness, ready to run a shell entry. */ @@ -53,12 +75,51 @@ interface BootReadyGlobal { __DSH_BOOT_READY__?: PromiseWithResolvers } +function bootReadyGate(): PromiseWithResolvers { + return (globalThis as BootReadyGlobal).__DSH_BOOT_READY__ ??= Promise.withResolvers() +} + +/** + * Install the page boot barrier before an asynchronous source chooser waits + * for user input. The later {@link connectWorkerHost} call settles the same + * barrier. + */ +function holdWorkerHostBoot(): void { + const ready = bootReadyGate() + // A chooser may remain open indefinitely; if a later connection fails before + // the stock entry subscribes, retain the rejection without browser noise. + void ready.promise.catch(() => {}) +} + +/** + * Run the optional pre-boot source-selection stage. Calling this stage holds + * the stock shell until the caller passes its result to {@link connectWorkerHost}; + * callers that need no chooser call `connectWorkerHost` directly and receive + * the base image with an empty overlay list. + * @param options - Base image and optional fixture-catalog locations. + * @returns The ordered overlays selected by the user. + */ +export async function chooseWorkerHostSource( + options: WorkerHostSourceOptions = {}, +): Promise { + holdWorkerHostBoot() + const image = new URL(options.image ?? IMAGE_FILE_NAME, document.baseURI) + const manifest = new URL(options.fixtureManifest ?? PREVIEW_FIXTURE_MANIFEST_FILE, image) + try { + const overlays = await choosePreviewSource(manifest) + return { overlays } + } catch (reason) { + bootReadyGate().reject(reason) + throw reason + } +} + /** * Connect a spawned host worker and complete the pre-Cordis handshake. * * The caller constructs the Worker so its bundler resolves the bundle URL - * statically; the opening `init` frame then carries the image location, the - * only input the worker takes from outside. + * statically; the opening `init` frame then carries the base image and ordered + * overlay locations. * * Order is fixed by the web boot protocol: the transport global must exist * before any bundle executes; the injection table then reproduces the served @@ -70,17 +131,20 @@ interface BootReadyGlobal { * row has taken effect, and surfaces a failed handshake instead of * proceeding on missing globals. * @param worker - The host worker. - * @param options - Image location override. + * @param options - Base-image and overlay location overrides. * @returns The connection; hand `loadBundle` to the shell entry's boot seam. */ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConnectOptions): Promise { - const ready = (globalThis as BootReadyGlobal).__DSH_BOOT_READY__ ??= Promise.withResolvers() + const ready = bootReadyGate() // The handshake may fail before any entry awaits the promise; this no-op // subscription keeps that from surfacing as an unhandled rejection. void ready.promise.catch(() => {}) try { const tunnel = new WorkerTunnel(worker) - tunnel.init(new URL(options?.image ?? IMAGE_FILE_NAME, document.baseURI).href) + tunnel.init( + new URL(options?.image ?? IMAGE_FILE_NAME, document.baseURI).href, + (options?.overlays ?? []).map(overlay => new URL(overlay, document.baseURI).href), + ) const payload = await tunnel.bootPayload() ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = { createApiClient: () => new WorkerApiClient(tunnel), diff --git a/packages/experimental/webworker-runtime/src/client/source-chooser.ts b/packages/experimental/webworker-runtime/src/client/source-chooser.ts new file mode 100644 index 0000000000..6a5174ccd3 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/client/source-chooser.ts @@ -0,0 +1,178 @@ +/** Pre-boot filesystem-source chooser for static WebWorker previews. */ + +import { + parsePreviewFixtureManifest, type PreviewFixtureManifestEntry, +} from '../fixture-manifest.ts' + +const EMPTY_SOURCE = 'none' +const WEBFS_SOURCE = 'webfs' +const PREVIEW_FIXTURE_QUERY = 'preview-fixture' + +interface PreviewSourceChoice { + readonly id: string + readonly label: string + readonly description: string + readonly overlays: readonly URL[] + readonly disabled?: boolean +} + +const CHOOSER_STYLE = ` + :root { color-scheme: light dark; } + body { margin: 0; } + [data-preview-source-chooser] { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + box-sizing: border-box; + color: #171717; + background: radial-gradient(circle at 50% 35%, #eef4ff 0, #f8fafc 42%, #f3f4f6 100%); + font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + [data-preview-source-card] { + width: min(560px, 100%); + box-sizing: border-box; + padding: 28px; + border: 1px solid #d8dee9; + border-radius: 20px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12); + } + [data-preview-source-card] h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.25; } + [data-preview-source-card] > p { margin: 0 0 22px; color: #5b6472; } + [data-preview-source-card] fieldset { display: grid; gap: 10px; margin: 0; padding: 0; border: 0; } + [data-preview-source-card] legend { margin-bottom: 10px; font-weight: 650; } + [data-preview-source-option] { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 12px; + padding: 14px; + border: 1px solid #d8dee9; + border-radius: 12px; + cursor: pointer; + } + [data-preview-source-option]:has(input:checked) { border-color: #4777df; background: #edf3ff; } + [data-preview-source-option]:has(input:disabled) { cursor: not-allowed; opacity: 0.55; } + [data-preview-source-option] input { grid-row: 1 / span 2; margin: 4px 0 0; } + [data-preview-source-option] strong { font-size: 15px; } + [data-preview-source-option] span { color: #667085; } + [data-preview-source-submit] { + width: 100%; + margin-top: 20px; + padding: 11px 16px; + border: 0; + border-radius: 10px; + color: white; + background: #315fc7; + font: inherit; + font-weight: 650; + cursor: pointer; + } + [data-preview-source-submit]:disabled { cursor: not-allowed; opacity: 0.5; } + @media (prefers-color-scheme: dark) { + [data-preview-source-chooser] { color: #f4f4f5; background: radial-gradient(circle at 50% 35%, #172554 0, #111827 45%, #09090b 100%); } + [data-preview-source-card] { border-color: #374151; background: rgba(24, 24, 27, 0.96); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); } + [data-preview-source-card] > p, [data-preview-source-option] span { color: #a1a1aa; } + [data-preview-source-option] { border-color: #3f3f46; } + [data-preview-source-option]:has(input:checked) { border-color: #7aa2ff; background: #172554; } + } +` + +const ENTITIES: Readonly> = { + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +} + +function escapeMarkup(value: string): string { + return value.replace(/[&<>"']/g, character => ENTITIES[character] ?? character) +} + +function optionMarkup(choice: PreviewSourceChoice, selected: string): string { + return `` +} + +function fixtureChoices(entries: readonly PreviewFixtureManifestEntry[], manifestUrl: URL): PreviewSourceChoice[] { + return entries.map(entry => ({ + id: entry.id, + label: entry.label, + description: entry.description, + overlays: entry.overlays.map(overlay => new URL(overlay, manifestUrl)), + })) +} + +/** + * Render the source chooser and wait for an enabled selection. + * @param manifestUrl - Built-in fixture catalog URL. + * @returns Ordered overlay URLs selected for the Worker mount. + */ +export async function choosePreviewSource(manifestUrl: URL): Promise { + const requested = new URL(location.href).searchParams.get(PREVIEW_FIXTURE_QUERY) + if (requested === EMPTY_SOURCE) return [] + + const response = await fetch(manifestUrl) + if (!response.ok) { + throw new Error(`preview source chooser: fixture manifest returned ${String(response.status)}`) + } + const manifest = parsePreviewFixtureManifest(await response.json()) + const choices: PreviewSourceChoice[] = [ + { + id: EMPTY_SOURCE, + label: '空白环境', + description: '只加载基础运行时,用于验证首次启动与新建 Workspace。', + overlays: [], + }, + ...fixtureChoices(manifest.fixtures, manifestUrl), + { + id: WEBFS_SOURCE, + label: 'WebFS 目录', + description: '需要用户授权的目录来源,将在 WebFS provider 接入后开放。', + overlays: [], + disabled: true, + }, + ] + if (requested !== null) { + const requestedChoice = choices.find(choice => choice.id === requested && choice.disabled !== true) + if (requestedChoice === undefined) { + throw new Error(`preview source chooser: unknown or interactive source "${requested}"`) + } + return requestedChoice.overlays + } + + const root = document.getElementById('root') + if (root === null) throw new Error('preview source chooser: missing #root') + const selected = manifest.defaultFixture ?? EMPTY_SOURCE + const style = document.createElement('style') + style.dataset.previewSourceStyle = '' + style.textContent = CHOOSER_STYLE + document.head.append(style) + + root.innerHTML = `
+
+

选择 Preview 数据源

+

数据会在 Worker 和应用启动前挂载;刷新页面可重新选择。

+
+ 文件系统来源 + ${choices.map(choice => optionMarkup(choice, selected)).join('')} +
+ +
+
` + const form = root.querySelector('[data-preview-source-card]') + if (form === null) throw new Error('preview source chooser: form was not rendered') + const sourceId = await new Promise((resolve, reject) => { + form.addEventListener('submit', (event) => { + event.preventDefault() + const value = new FormData(form).get('preview-source') + if (typeof value === 'string') resolve(value) + else reject(new Error('preview source chooser: no source selected')) + }, { once: true }) + }) + const choice = choices.find(candidate => candidate.id === sourceId && candidate.disabled !== true) + if (choice === undefined) throw new Error(`preview source chooser: unavailable source "${sourceId}"`) + root.replaceChildren() + style.remove() + return choice.overlays +} diff --git a/packages/experimental/webworker-runtime/src/fixture-manifest.ts b/packages/experimental/webworker-runtime/src/fixture-manifest.ts new file mode 100644 index 0000000000..d594960576 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/fixture-manifest.ts @@ -0,0 +1,67 @@ +/** Browser-readable catalog of built-in Preview filesystem overlays. */ + +/** Manifest format version emitted beside the base VFS image. */ +export const PREVIEW_FIXTURE_MANIFEST_VERSION = 1 + +/** Leaf name resolved beside the base image. */ +export const PREVIEW_FIXTURE_MANIFEST_FILE = 'fixtures.json' + +/** One selectable built-in fixture and its ordered overlay archives. */ +export interface PreviewFixtureManifestEntry { + readonly id: string + readonly label: string + readonly description: string + readonly overlays: readonly string[] +} + +/** Complete built-in fixture catalog consumed before Worker startup. */ +export interface PreviewFixtureManifest { + readonly version: number + readonly defaultFixture: string | null + readonly fixtures: readonly PreviewFixtureManifestEntry[] +} + +function recordOf(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +/** + * Validate the static fixture catalog before it controls Worker fetches. + * @param value - Parsed JSON response. + * @returns A detached manifest with unique ids and non-empty overlay lists. + */ +export function parsePreviewFixtureManifest(value: unknown): PreviewFixtureManifest { + const record = recordOf(value) + if (record?.version !== PREVIEW_FIXTURE_MANIFEST_VERSION || !Array.isArray(record.fixtures)) { + throw new Error(`preview fixture manifest must use version ${String(PREVIEW_FIXTURE_MANIFEST_VERSION)}`) + } + const fixtures: PreviewFixtureManifestEntry[] = [] + const ids = new Set() + for (const value of record.fixtures) { + const fixture = recordOf(value) + const id = fixture?.id + const label = fixture?.label + const description = fixture?.description + const overlays = fixture?.overlays + const overlayUrls = Array.isArray(overlays) + ? overlays.filter((overlay): overlay is string => typeof overlay === 'string' && overlay.length > 0) + : [] + if (typeof id !== 'string' || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(id) + || id === 'none' || id === 'webfs' + || typeof label !== 'string' || label.length === 0 + || typeof description !== 'string' || description.length === 0 + || !Array.isArray(overlays) || overlays.length === 0 || overlayUrls.length !== overlays.length) { + throw new Error('preview fixture manifest contains an invalid fixture entry') + } + if (ids.has(id)) throw new Error(`preview fixture manifest repeats id "${id}"`) + ids.add(id) + fixtures.push({ id, label, description, overlays: overlayUrls }) + } + const defaultFixture = record.defaultFixture + if (defaultFixture !== null && (typeof defaultFixture !== 'string' || !ids.has(defaultFixture))) { + throw new Error('preview fixture manifest defaultFixture does not name a fixture') + } + return { version: PREVIEW_FIXTURE_MANIFEST_VERSION, defaultFixture, fixtures } +} diff --git a/packages/experimental/webworker-runtime/src/image-layout.ts b/packages/experimental/webworker-runtime/src/image-layout.ts index 83693ea292..3e16f5a529 100644 --- a/packages/experimental/webworker-runtime/src/image-layout.ts +++ b/packages/experimental/webworker-runtime/src/image-layout.ts @@ -9,9 +9,9 @@ export const DEFAULT_ROOT = '/dsh' /** - * Leaf name of the packed image: one gzip member holding the ustar archive. The - * app build writes it beside the page and the page's boot fetches it from there, - * so the extension is part of what a deployment serves. + * Leaf name of the packed base image: one gzip member holding the ustar archive. + * The app build writes it beside the page and the page's boot fetches it from + * there, so the extension is part of what a deployment serves. */ export const IMAGE_FILE_NAME = 'vfs-image.tar.gz' @@ -27,6 +27,12 @@ export const IMAGE_HOME_DIRECTORY = 'home' /** Working directories the host tree expects to exist, empty. */ export const IMAGE_EMPTY_DIRECTORIES: readonly string[] = ['home/', 'workspace/', 'tmp/'] +/** + * Top-level directories an overlay archive may populate. Runtime code, + * configuration, and the lowering manifest remain owned by the base image. + */ +export const IMAGE_OVERLAY_DIRECTORIES: readonly string[] = ['home', 'workspace'] + /** * Identity of the lowered code shape, recorded in the image manifest by the * packer and required by the worker host: an image lowered by an older transform diff --git a/packages/experimental/webworker-runtime/src/index.ts b/packages/experimental/webworker-runtime/src/index.ts index cf39694ca0..e24faa1eb7 100644 --- a/packages/experimental/webworker-runtime/src/index.ts +++ b/packages/experimental/webworker-runtime/src/index.ts @@ -34,9 +34,13 @@ export { } from './worker-host.ts' export { DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_FILE_NAME, IMAGE_HOME_DIRECTORY, - IMAGE_MANIFEST_PATH, LOWERING_VERSION, WRAPPER_PARAMS, + IMAGE_MANIFEST_PATH, IMAGE_OVERLAY_DIRECTORIES, LOWERING_VERSION, WRAPPER_PARAMS, } from './image-layout.ts' -export { loadVfsImage, MemoryVfs } from './storage/memory.ts' +export { + parsePreviewFixtureManifest, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, + type PreviewFixtureManifest, type PreviewFixtureManifestEntry, +} from './fixture-manifest.ts' +export { loadVfsImage, loadVfsOverlay, MemoryVfs } from './storage/memory.ts' export { inflateImage, inflateImageStream } from './storage/image-gzip.ts' export { packTar, parseTar, type TarEntry } from './storage/tar.ts' export { requireActiveVfs, setActiveVfs } from './storage/active.ts' diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts index 396ae3c22e..71864a7caf 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts @@ -413,6 +413,7 @@ export function watchAsync( throw(reason?: unknown): Promise> { close() // AsyncIterator.throw forwards the caller's exact reason, including non-Error values. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors return Promise.reject(reason) }, } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts index 757961a42a..a7fca23540 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts @@ -23,6 +23,7 @@ type StreamStatics = typeof import('node:stream').Stream & { const nodeStream = Stream as unknown as StreamRuntime +/* oxlint-disable typescript/unbound-method -- readable-stream's namespace statics do not read `this`. */ const { Duplex, PassThrough, Readable, Stream: StreamBase, Transform, Writable, addAbortSignal, compose, destroy, finished, isDisturbed, isErrored, isReadable, pipeline, promises, @@ -31,6 +32,7 @@ const streamStatics = StreamBase as unknown as StreamStatics const { getDefaultHighWaterMark, isDestroyed, isWritable, setDefaultHighWaterMark, } = streamStatics +/* oxlint-enable typescript/unbound-method */ // readable-stream tracks Node 18's 16 KiB byte default; this repository runs // Node 22+, whose generic and file streams use 64 KiB. diff --git a/packages/experimental/webworker-runtime/src/shell/fs-access.ts b/packages/experimental/webworker-runtime/src/shell/fs-access.ts index 48da48302a..9099c43559 100644 --- a/packages/experimental/webworker-runtime/src/shell/fs-access.ts +++ b/packages/experimental/webworker-runtime/src/shell/fs-access.ts @@ -76,13 +76,13 @@ function statsOf(stats: VfsStats): ShellStats { */ export function hostFileSystem(): ShellFileSystem { const vfs = (): ReturnType => requireActiveVfs() - const stat = async (path: string): Promise => { + const stat = (path: string): Promise => { try { - return statsOf(vfs().statSync(path) as VfsStats) + return Promise.resolve(statsOf(vfs().statSync(path) as VfsStats)) } catch { // Absence is the answer callers branch on; every other failure mode of // the in-memory backend is also "this path holds nothing readable". - return undefined + return Promise.resolve(undefined) } } // Several members take no await: the face is asynchronous because a process @@ -101,18 +101,22 @@ export function hostFileSystem(): ShellFileSystem { if ((await stat(path))?.directory === true) throw filesystemError('EISDIR', 'read', path) return vfs().readFileSync(path, 'utf8') as string }, - writeText: async (path: string, text: string, append = false): Promise => { + writeText: (path: string, text: string, append = false): Promise => { if (append) vfs().appendFileSync(path, text) else vfs().writeFileSync(path, text) + return Promise.resolve() }, - mkdir: async (path: string, recursive: boolean): Promise => { + mkdir: (path: string, recursive: boolean): Promise => { vfs().mkdirSync(path, { recursive }) + return Promise.resolve() }, - remove: async (path: string, options: { recursive: boolean; force: boolean }): Promise => { + remove: (path: string, options: { recursive: boolean; force: boolean }): Promise => { vfs().rmSync(path, options) + return Promise.resolve() }, - rename: async (from: string, to: string): Promise => { + rename: (from: string, to: string): Promise => { vfs().renameSync(from, to) + return Promise.resolve() }, } } diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 96a63f859f..6f41fbc9ee 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -5,6 +5,7 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory */ import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-path.ts' +import { IMAGE_OVERLAY_DIRECTORIES } from '../image-layout.ts' import { parseTar } from './tar.ts' import type { Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, @@ -773,3 +774,38 @@ export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryV } return vfs } + +/** + * Apply one ordered data overlay to an already mounted base image. + * + * Overlay entries may replace files only under the layout's data directories; + * module code, configuration, and the lowering manifest cannot be shadowed. + * Paths containing traversal segments are refused before normalization. Later + * overlays win for files, while file/directory type conflicts fail loud. + * @param image - Uncompressed ustar overlay archive. + * @param root - Virtual root shared with the base image. + * @param vfs - Mounted filesystem to update. + * @returns The same filesystem after applying the overlay. + */ +export function loadVfsOverlay(image: Uint8Array, root: string, vfs: MemoryVfs): MemoryVfs { + for (const entry of parseTar(image)) { + const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name + const path = relativeName.endsWith('/') ? relativeName.slice(0, -1) : relativeName + const segments = path.split('/') + if (path === '' || relativeName.startsWith(SEP) + || segments.some(segment => segment === '' || segment === '.' || segment === '..') + || !IMAGE_OVERLAY_DIRECTORIES.includes(segments[0] ?? '')) { + throw new Error(`webworker vfs: overlay entry must stay under ${IMAGE_OVERLAY_DIRECTORIES.join('/ or ')}, received "${entry.name}"`) + } + const target = join(root, path) + if (entry.directory) { + vfs.seedDirectory(target, { mode: entry.mode }) + continue + } + if (vfs.existsSync(target) && vfs.statSync(target).isDirectory()) { + throw new Error(`webworker vfs: overlay file cannot replace directory "${target}"`) + } + vfs.seed(target, entry.bytes, { mode: entry.mode }) + } + return vfs +} diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index 67d89b73fd..a04a2cec5a 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -33,12 +33,13 @@ export interface TunnelAbortFrame { /** Frames the worker accepts. */ /** - * First inbound frame: the image URL, the one input the worker assembly - * takes from outside. + * First inbound frame: the base image URL and ordered data overlays selected + * before the worker assembly starts. */ export interface TunnelInitFrame { readonly t: 'init' readonly image: string + readonly overlays: readonly string[] } /** Every frame the page sends the worker. */ @@ -142,7 +143,10 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { if (typeof frame.image !== 'string') { throw new Error('webworker tunnel: init frame needs a string image url') } - return { t: 'init', image: frame.image } + if (!Array.isArray(frame.overlays) || frame.overlays.some(overlay => typeof overlay !== 'string')) { + throw new Error('webworker tunnel: init frame needs an array of string overlay urls') + } + return { t: 'init', image: frame.image, overlays: frame.overlays as string[] } } const id = frame.id if (typeof id !== 'string' && typeof id !== 'number') { diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index f73f1d2e5c..bbbf6a3900 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -29,7 +29,7 @@ import { installProcessGlobal } from './node/globals/process.ts' import type { RequestListener } from './transport/synthetic-http.ts' import { TunnelServer, type TunnelPort } from './transport/tunnel.ts' import { inflateImage, inflateImageStream } from './storage/image-gzip.ts' -import { loadVfsImage, MemoryVfs } from './storage/memory.ts' +import { loadVfsImage, loadVfsOverlay, MemoryVfs } from './storage/memory.ts' import { setActiveVfs } from './storage/active.ts' import { DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_HOME_DIRECTORY, IMAGE_MANIFEST_PATH, @@ -87,6 +87,8 @@ export interface WorkerHostOptions { readonly requestListener: () => Promise /** Image bytes, or the URL the worker fetches them from. */ readonly image: Uint8Array | string + /** Ordered data overlays applied after the base image and before boot. */ + readonly overlays?: readonly (Uint8Array | string)[] /** Virtual root; defaults to {@link DEFAULT_ROOT}. */ readonly root?: string /** Composed configuration inside the image; defaults to `/config/cordis.yml`. */ @@ -182,8 +184,12 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const home = join(root, IMAGE_HOME_DIRECTORY) installProcessGlobal({ cwd: root, env: { DSH_HOME: home, HOME: home, ...options.env } }) - const bytes = await readImage(options.image) + const [bytes, overlays] = await Promise.all([ + readImage(options.image), + Promise.all((options.overlays ?? []).map(readImage)), + ]) const mounted = loadVfsImage(bytes, root) + for (const overlay of overlays) loadVfsOverlay(overlay, root, mounted) // Belt and braces over the image's own empty-directory entries: a hand // -built image without them still boots. for (const directory of IMAGE_EMPTY_DIRECTORIES) { @@ -248,7 +254,7 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const shared = ctx.get('connection') !== undefined const handler = directFetchHandler(ctx, toFetchHandler(apiProxy)) const usage = loader.usage() - console.info(`webworker host: tree active (modules=${String(usage.modules)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) + console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), diff --git a/packages/experimental/webworker-runtime/src/worker.ts b/packages/experimental/webworker-runtime/src/worker.ts index f82c5fd1fb..f0912aef93 100644 --- a/packages/experimental/webworker-runtime/src/worker.ts +++ b/packages/experimental/webworker-runtime/src/worker.ts @@ -4,9 +4,9 @@ * listener; the assembly owns everything else (process global, VFS image, * Cordis tree, tunnel server). * - * The assembly needs the image location before it can exist, and it arrives in - * the tunnel's opening `init` frame — this bundle reads nothing from its own - * URL, so the deployment decides where both the bundle and the image live. + * The assembly needs the base image and selected overlays before it can exist; + * they arrive in the tunnel's opening `init` frame. This bundle reads nothing + * from its own URL, so the deployment decides where every archive lives. * Messages before `init` queue here; requests during boot queue inside the * host, which attaches its handler before its first await. */ @@ -52,12 +52,16 @@ self.addEventListener('message', (event: MessageEvent) => { if (typeof data.image !== 'string') { throw new Error('webworker: init frame needs a string image url') } + if (!Array.isArray(data.overlays) || data.overlays.some(overlay => typeof overlay !== 'string')) { + throw new Error('webworker: init frame needs an array of string overlay urls') + } const created = createWorkerHost({ staticModules: createNodeBuiltins(), staticModulePrefixes: REPLACED_PREFIXES, requestListener: whenRequestListener, alsCausality, image: data.image, + overlays: data.overlays as string[], }) host = created for (const queued of pending) { diff --git a/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts b/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts new file mode 100644 index 0000000000..6c629a2af8 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreviewFixtureManifest } from '../../src/fixture-manifest.ts' +import { choosePreviewSource } from '../../src/client/source-chooser.ts' + +const MANIFEST_URL = new URL('https://preview.test/preview/fixtures.json') + +const MANIFEST: PreviewFixtureManifest = { + version: 1, + defaultFixture: 'example', + fixtures: [{ + id: 'example', + label: 'Example & "quoted" \'single\'', + description: 'A deterministic example.', + overlays: ['fixtures/base.tar.gz', 'fixtures/tail.tar.gz'], + }], +} + +function setLocation(search = ''): void { + history.replaceState({}, '', `/preview.html${search}`) +} + +function installManifest(manifest: PreviewFixtureManifest = MANIFEST): ReturnType { + const fetch = vi.fn(async () => Response.json(manifest)) + vi.stubGlobal('fetch', fetch) + return fetch +} + +function submitChooser(): void { + const form = document.querySelector('[data-preview-source-card]') + if (form === null) throw new Error('test chooser form was not rendered') + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) +} + +describe('Preview source chooser', () => { + beforeEach(() => { + document.head.replaceChildren() + document.body.innerHTML = '
' + setLocation() + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('bypasses the chooser and manifest for an explicit empty source', async () => { + setLocation('?preview-fixture=none') + const fetch = installManifest() + + await expect(choosePreviewSource(MANIFEST_URL)).resolves.toEqual([]) + expect(fetch).not.toHaveBeenCalled() + expect(document.querySelector('[data-preview-source-chooser]')).toBeNull() + }) + + it('bypasses the chooser and resolves an explicit built-in fixture', async () => { + document.body.replaceChildren() + setLocation('?preview-fixture=example') + const fetch = installManifest() + + await expect(choosePreviewSource(MANIFEST_URL)).resolves.toEqual([ + new URL('https://preview.test/preview/fixtures/base.tar.gz'), + new URL('https://preview.test/preview/fixtures/tail.tar.gz'), + ]) + expect(fetch).toHaveBeenCalledOnce() + expect(document.querySelector('[data-preview-source-chooser]')).toBeNull() + }) + + it.each(['', 'missing', 'webfs'])( + 'fails loud for the explicit unavailable source %j without opening the chooser', + async (source) => { + setLocation(`?preview-fixture=${source}`) + installManifest() + + await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/unknown or interactive source/) + expect(document.querySelector('[data-preview-source-chooser]')).toBeNull() + }, + ) + + it('shows the chooser only when the query is absent and returns its default selection', async () => { + installManifest() + + const selected = choosePreviewSource(MANIFEST_URL) + await vi.waitFor(() => { + expect(document.querySelector('[data-preview-source-chooser]')).not.toBeNull() + }) + expect(document.querySelector('input[value="example"]')?.checked).toBe(true) + expect(document.querySelector('input[value="webfs"]')?.disabled).toBe(true) + expect(document.querySelector('[data-preview-source-card]')?.textContent) + .toContain(MANIFEST.fixtures[0]?.label) + expect(document.querySelector('[data-preview-source-card] script')).toBeNull() + + submitChooser() + + await expect(selected).resolves.toEqual([ + new URL('https://preview.test/preview/fixtures/base.tar.gz'), + new URL('https://preview.test/preview/fixtures/tail.tar.gz'), + ]) + expect(document.getElementById('root')?.childElementCount).toBe(0) + expect(document.querySelector('[data-preview-source-style]')).toBeNull() + }) + + it('selects the empty source when the manifest has no default', async () => { + installManifest({ ...MANIFEST, defaultFixture: null }) + + const selected = choosePreviewSource(MANIFEST_URL) + await vi.waitFor(() => { + expect(document.querySelector('input[value="none"]')?.checked).toBe(true) + }) + submitChooser() + + await expect(selected).resolves.toEqual([]) + }) + + it('reports manifest, mount, form, selection, and catalog failures', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('missing', { status: 404 }))) + await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/returned 404/) + + installManifest() + document.body.replaceChildren() + await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/missing #root/) + + document.body.innerHTML = '
' + const root = document.getElementById('root') + if (root === null) throw new Error('test root is missing') + vi.spyOn(root, 'querySelector').mockReturnValueOnce(null) + await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/form was not rendered/) + + document.head.replaceChildren() + document.body.innerHTML = '
' + const missingSelection = choosePreviewSource(MANIFEST_URL) + await vi.waitFor(() => { expect(document.querySelector('form')).not.toBeNull() }) + document.querySelectorAll('input[name="preview-source"]').forEach((input) => { + input.removeAttribute('name') + }) + submitChooser() + await expect(missingSelection).rejects.toThrow(/no source selected/) + + document.head.replaceChildren() + document.body.innerHTML = '
' + const unavailableSelection = choosePreviewSource(MANIFEST_URL) + await vi.waitFor(() => { expect(document.querySelector('form')).not.toBeNull() }) + const selected = document.querySelector('input:checked') + if (selected === null) throw new Error('test selection is missing') + selected.value = 'missing' + submitChooser() + await expect(unavailableSelection).rejects.toThrow(/unavailable source/) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/fixture-manifest.spec.ts b/packages/experimental/webworker-runtime/tests/fixture-manifest.spec.ts new file mode 100644 index 0000000000..01b3d00e9a --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixture-manifest.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + parsePreviewFixtureManifest, PREVIEW_FIXTURE_MANIFEST_VERSION, +} from '../src/fixture-manifest.ts' + +describe('Preview fixture manifest', () => { + it('accepts a unique named fixture with ordered overlays', () => { + expect(parsePreviewFixtureManifest({ + version: PREVIEW_FIXTURE_MANIFEST_VERSION, + defaultFixture: 'example', + fixtures: [{ + id: 'example', + label: 'Example', + description: 'A deterministic example.', + overlays: ['fixtures/base.tar.gz', 'fixtures/tail.tar.gz'], + }], + })).toEqual({ + version: PREVIEW_FIXTURE_MANIFEST_VERSION, + defaultFixture: 'example', + fixtures: [{ + id: 'example', + label: 'Example', + description: 'A deterministic example.', + overlays: ['fixtures/base.tar.gz', 'fixtures/tail.tar.gz'], + }], + }) + }) + + it.each([ + [{ version: 2, defaultFixture: null, fixtures: [] }, /must use version/], + [{ version: 1, defaultFixture: 'missing', fixtures: [] }, /defaultFixture/], + [{ + version: 1, + defaultFixture: 'duplicate', + fixtures: [ + { id: 'duplicate', label: 'One', description: 'First.', overlays: ['one.tar.gz'] }, + { id: 'duplicate', label: 'Two', description: 'Second.', overlays: ['two.tar.gz'] }, + ], + }, /repeats id/], + [{ + version: 1, + defaultFixture: 'none', + fixtures: [{ id: 'none', label: 'None', description: 'Reserved.', overlays: ['none.tar.gz'] }], + }, /invalid fixture entry/], + ])('rejects malformed catalogs', (value, error) => { + expect(() => parsePreviewFixtureManifest(value)).toThrow(error) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl new file mode 100644 index 0000000000..291a18ba1e --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl @@ -0,0 +1,178 @@ +{"type":"session","version":0,"id":"preview-architecture-review","createdAt":1787472100000,"cwd":"/dsh/workspace","parentSession":"preview-showcase","seedLength":169,"origin":"subagent","delegationDepth":1,"agentPreset":"standard"} +{"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472000000} +{"type":"user/message","data":{"id":"preview-user-01","role":"user","content":[{"type":"text","text":"History checkpoint 01: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472000001} +{"type":"session/title","data":{"title":"WebWorker Preview Showcase","messageSeqs":[],"source":{"kind":"user"}},"seq":2,"time":1787472000002} +{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472000003} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-assistant-01","role":"assistant","content":[{"type":"text","text":"Checkpoint 01 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":4,"time":1787472000004} +{"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472000005} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":6,"time":1787472000006} +{"type":"turn/start","data":{"turn":2},"seq":7,"time":1787472000007} +{"type":"user/message","data":{"id":"preview-user-02","role":"user","content":[{"type":"text","text":"History checkpoint 02: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":8,"time":1787472000008} +{"type":"step/start","data":{"turn":2,"step":1},"seq":9,"time":1787472000009} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"id":"preview-assistant-02","role":"assistant","content":[{"type":"text","text":"Checkpoint 02 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":10,"time":1787472000010} +{"type":"step/end","data":{"turn":2,"step":1},"seq":11,"time":1787472000011} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":12,"time":1787472000012} +{"type":"turn/start","data":{"turn":3},"seq":13,"time":1787472000013} +{"type":"user/message","data":{"id":"preview-user-03","role":"user","content":[{"type":"text","text":"History checkpoint 03: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":14,"time":1787472000014} +{"type":"step/start","data":{"turn":3,"step":1},"seq":15,"time":1787472000015} +{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"id":"preview-assistant-03","role":"assistant","content":[{"type":"text","text":"Checkpoint 03 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":16,"time":1787472000016} +{"type":"step/end","data":{"turn":3,"step":1},"seq":17,"time":1787472000017} +{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":18,"time":1787472000018} +{"type":"turn/start","data":{"turn":4},"seq":19,"time":1787472000019} +{"type":"user/message","data":{"id":"preview-user-04","role":"user","content":[{"type":"text","text":"History checkpoint 04: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":20,"time":1787472000020} +{"type":"step/start","data":{"turn":4,"step":1},"seq":21,"time":1787472000021} +{"type":"assistant/message","data":{"turn":4,"step":1,"message":{"id":"preview-assistant-04","role":"assistant","content":[{"type":"text","text":"Checkpoint 04 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":22,"time":1787472000022} +{"type":"step/end","data":{"turn":4,"step":1},"seq":23,"time":1787472000023} +{"type":"turn/end","data":{"turn":4,"reason":{"kind":"completed"}},"seq":24,"time":1787472000024} +{"type":"turn/start","data":{"turn":5},"seq":25,"time":1787472000025} +{"type":"user/message","data":{"id":"preview-user-05","role":"user","content":[{"type":"text","text":"History checkpoint 05: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":26,"time":1787472000026} +{"type":"step/start","data":{"turn":5,"step":1},"seq":27,"time":1787472000027} +{"type":"assistant/message","data":{"turn":5,"step":1,"message":{"id":"preview-assistant-05","role":"assistant","content":[{"type":"text","text":"Checkpoint 05 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":28,"time":1787472000028} +{"type":"step/end","data":{"turn":5,"step":1},"seq":29,"time":1787472000029} +{"type":"turn/end","data":{"turn":5,"reason":{"kind":"completed"}},"seq":30,"time":1787472000030} +{"type":"turn/start","data":{"turn":6},"seq":31,"time":1787472000031} +{"type":"user/message","data":{"id":"preview-user-06","role":"user","content":[{"type":"text","text":"History checkpoint 06: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":32,"time":1787472000032} +{"type":"step/start","data":{"turn":6,"step":1},"seq":33,"time":1787472000033} +{"type":"assistant/message","data":{"turn":6,"step":1,"message":{"id":"preview-assistant-06","role":"assistant","content":[{"type":"text","text":"Checkpoint 06 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":34,"time":1787472000034} +{"type":"step/end","data":{"turn":6,"step":1},"seq":35,"time":1787472000035} +{"type":"turn/end","data":{"turn":6,"reason":{"kind":"completed"}},"seq":36,"time":1787472000036} +{"type":"turn/start","data":{"turn":7},"seq":37,"time":1787472000037} +{"type":"user/message","data":{"id":"preview-user-07","role":"user","content":[{"type":"text","text":"History checkpoint 07: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":38,"time":1787472000038} +{"type":"step/start","data":{"turn":7,"step":1},"seq":39,"time":1787472000039} +{"type":"assistant/message","data":{"turn":7,"step":1,"message":{"id":"preview-assistant-07","role":"assistant","content":[{"type":"text","text":"Checkpoint 07 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":40,"time":1787472000040} +{"type":"step/end","data":{"turn":7,"step":1},"seq":41,"time":1787472000041} +{"type":"turn/end","data":{"turn":7,"reason":{"kind":"completed"}},"seq":42,"time":1787472000042} +{"type":"turn/start","data":{"turn":8},"seq":43,"time":1787472000043} +{"type":"user/message","data":{"id":"preview-user-08","role":"user","content":[{"type":"text","text":"History checkpoint 08: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":44,"time":1787472000044} +{"type":"step/start","data":{"turn":8,"step":1},"seq":45,"time":1787472000045} +{"type":"assistant/message","data":{"turn":8,"step":1,"message":{"id":"preview-assistant-08","role":"assistant","content":[{"type":"text","text":"Checkpoint 08 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":46,"time":1787472000046} +{"type":"step/end","data":{"turn":8,"step":1},"seq":47,"time":1787472000047} +{"type":"turn/end","data":{"turn":8,"reason":{"kind":"completed"}},"seq":48,"time":1787472000048} +{"type":"turn/start","data":{"turn":9},"seq":49,"time":1787472000049} +{"type":"user/message","data":{"id":"preview-user-09","role":"user","content":[{"type":"text","text":"History checkpoint 09: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":50,"time":1787472000050} +{"type":"step/start","data":{"turn":9,"step":1},"seq":51,"time":1787472000051} +{"type":"assistant/message","data":{"turn":9,"step":1,"message":{"id":"preview-assistant-09","role":"assistant","content":[{"type":"text","text":"Checkpoint 09 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":52,"time":1787472000052} +{"type":"step/end","data":{"turn":9,"step":1},"seq":53,"time":1787472000053} +{"type":"turn/end","data":{"turn":9,"reason":{"kind":"completed"}},"seq":54,"time":1787472000054} +{"type":"turn/start","data":{"turn":10},"seq":55,"time":1787472000055} +{"type":"user/message","data":{"id":"preview-user-10","role":"user","content":[{"type":"text","text":"History checkpoint 10: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":56,"time":1787472000056} +{"type":"step/start","data":{"turn":10,"step":1},"seq":57,"time":1787472000057} +{"type":"assistant/message","data":{"turn":10,"step":1,"message":{"id":"preview-assistant-10","role":"assistant","content":[{"type":"text","text":"Checkpoint 10 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":58,"time":1787472000058} +{"type":"step/end","data":{"turn":10,"step":1},"seq":59,"time":1787472000059} +{"type":"turn/end","data":{"turn":10,"reason":{"kind":"completed"}},"seq":60,"time":1787472000060} +{"type":"turn/start","data":{"turn":11},"seq":61,"time":1787472000061} +{"type":"user/message","data":{"id":"preview-user-11","role":"user","content":[{"type":"text","text":"History checkpoint 11: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":62,"time":1787472000062} +{"type":"step/start","data":{"turn":11,"step":1},"seq":63,"time":1787472000063} +{"type":"assistant/message","data":{"turn":11,"step":1,"message":{"id":"preview-assistant-11","role":"assistant","content":[{"type":"text","text":"Checkpoint 11 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":64,"time":1787472000064} +{"type":"step/end","data":{"turn":11,"step":1},"seq":65,"time":1787472000065} +{"type":"turn/end","data":{"turn":11,"reason":{"kind":"completed"}},"seq":66,"time":1787472000066} +{"type":"turn/start","data":{"turn":12},"seq":67,"time":1787472000067} +{"type":"user/message","data":{"id":"preview-user-12","role":"user","content":[{"type":"text","text":"History checkpoint 12: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":68,"time":1787472000068} +{"type":"step/start","data":{"turn":12,"step":1},"seq":69,"time":1787472000069} +{"type":"assistant/message","data":{"turn":12,"step":1,"message":{"id":"preview-assistant-12","role":"assistant","content":[{"type":"text","text":"Checkpoint 12 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":70,"time":1787472000070} +{"type":"step/end","data":{"turn":12,"step":1},"seq":71,"time":1787472000071} +{"type":"turn/end","data":{"turn":12,"reason":{"kind":"completed"}},"seq":72,"time":1787472000072} +{"type":"turn/start","data":{"turn":13},"seq":73,"time":1787472000073} +{"type":"user/message","data":{"id":"preview-user-13","role":"user","content":[{"type":"text","text":"History checkpoint 13: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":74,"time":1787472000074} +{"type":"step/start","data":{"turn":13,"step":1},"seq":75,"time":1787472000075} +{"type":"assistant/message","data":{"turn":13,"step":1,"message":{"id":"preview-assistant-13","role":"assistant","content":[{"type":"text","text":"Checkpoint 13 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":76,"time":1787472000076} +{"type":"step/end","data":{"turn":13,"step":1},"seq":77,"time":1787472000077} +{"type":"turn/end","data":{"turn":13,"reason":{"kind":"completed"}},"seq":78,"time":1787472000078} +{"type":"turn/start","data":{"turn":14},"seq":79,"time":1787472000079} +{"type":"user/message","data":{"id":"preview-user-14","role":"user","content":[{"type":"text","text":"History checkpoint 14: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":80,"time":1787472000080} +{"type":"step/start","data":{"turn":14,"step":1},"seq":81,"time":1787472000081} +{"type":"assistant/message","data":{"turn":14,"step":1,"message":{"id":"preview-assistant-14","role":"assistant","content":[{"type":"text","text":"Checkpoint 14 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":82,"time":1787472000082} +{"type":"step/end","data":{"turn":14,"step":1},"seq":83,"time":1787472000083} +{"type":"turn/end","data":{"turn":14,"reason":{"kind":"completed"}},"seq":84,"time":1787472000084} +{"type":"turn/start","data":{"turn":15},"seq":85,"time":1787472000085} +{"type":"user/message","data":{"id":"preview-user-15","role":"user","content":[{"type":"text","text":"History checkpoint 15: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":86,"time":1787472000086} +{"type":"step/start","data":{"turn":15,"step":1},"seq":87,"time":1787472000087} +{"type":"assistant/message","data":{"turn":15,"step":1,"message":{"id":"preview-assistant-15","role":"assistant","content":[{"type":"text","text":"Checkpoint 15 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":88,"time":1787472000088} +{"type":"step/end","data":{"turn":15,"step":1},"seq":89,"time":1787472000089} +{"type":"turn/end","data":{"turn":15,"reason":{"kind":"completed"}},"seq":90,"time":1787472000090} +{"type":"turn/start","data":{"turn":16},"seq":91,"time":1787472000091} +{"type":"user/message","data":{"id":"preview-user-16","role":"user","content":[{"type":"text","text":"History checkpoint 16: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":92,"time":1787472000092} +{"type":"step/start","data":{"turn":16,"step":1},"seq":93,"time":1787472000093} +{"type":"assistant/message","data":{"turn":16,"step":1,"message":{"id":"preview-assistant-16","role":"assistant","content":[{"type":"text","text":"Checkpoint 16 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":94,"time":1787472000094} +{"type":"step/end","data":{"turn":16,"step":1},"seq":95,"time":1787472000095} +{"type":"turn/end","data":{"turn":16,"reason":{"kind":"completed"}},"seq":96,"time":1787472000096} +{"type":"turn/start","data":{"turn":17},"seq":97,"time":1787472000097} +{"type":"user/message","data":{"id":"preview-user-17","role":"user","content":[{"type":"text","text":"History checkpoint 17: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":98,"time":1787472000098} +{"type":"step/start","data":{"turn":17,"step":1},"seq":99,"time":1787472000099} +{"type":"assistant/message","data":{"turn":17,"step":1,"message":{"id":"preview-assistant-17","role":"assistant","content":[{"type":"text","text":"Checkpoint 17 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":100,"time":1787472000100} +{"type":"step/end","data":{"turn":17,"step":1},"seq":101,"time":1787472000101} +{"type":"turn/end","data":{"turn":17,"reason":{"kind":"completed"}},"seq":102,"time":1787472000102} +{"type":"turn/start","data":{"turn":18},"seq":103,"time":1787472000103} +{"type":"user/message","data":{"id":"preview-user-18","role":"user","content":[{"type":"text","text":"History checkpoint 18: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":104,"time":1787472000104} +{"type":"step/start","data":{"turn":18,"step":1},"seq":105,"time":1787472000105} +{"type":"assistant/message","data":{"turn":18,"step":1,"message":{"id":"preview-assistant-18","role":"assistant","content":[{"type":"text","text":"Checkpoint 18 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":106,"time":1787472000106} +{"type":"step/end","data":{"turn":18,"step":1},"seq":107,"time":1787472000107} +{"type":"turn/end","data":{"turn":18,"reason":{"kind":"completed"}},"seq":108,"time":1787472000108} +{"type":"turn/start","data":{"turn":19},"seq":109,"time":1787472000109} +{"type":"user/message","data":{"id":"preview-user-19","role":"user","content":[{"type":"text","text":"History checkpoint 19: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":110,"time":1787472000110} +{"type":"step/start","data":{"turn":19,"step":1},"seq":111,"time":1787472000111} +{"type":"assistant/message","data":{"turn":19,"step":1,"message":{"id":"preview-assistant-19","role":"assistant","content":[{"type":"text","text":"Checkpoint 19 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":112,"time":1787472000112} +{"type":"step/end","data":{"turn":19,"step":1},"seq":113,"time":1787472000113} +{"type":"turn/end","data":{"turn":19,"reason":{"kind":"completed"}},"seq":114,"time":1787472000114} +{"type":"turn/start","data":{"turn":20},"seq":115,"time":1787472000115} +{"type":"user/message","data":{"id":"preview-user-20","role":"user","content":[{"type":"text","text":"History checkpoint 20: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":116,"time":1787472000116} +{"type":"step/start","data":{"turn":20,"step":1},"seq":117,"time":1787472000117} +{"type":"assistant/message","data":{"turn":20,"step":1,"message":{"id":"preview-assistant-20","role":"assistant","content":[{"type":"text","text":"Checkpoint 20 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":118,"time":1787472000118} +{"type":"step/end","data":{"turn":20,"step":1},"seq":119,"time":1787472000119} +{"type":"turn/end","data":{"turn":20,"reason":{"kind":"completed"}},"seq":120,"time":1787472000120} +{"type":"turn/start","data":{"turn":21},"seq":121,"time":1787472000121} +{"type":"user/message","data":{"id":"preview-user-21","role":"user","content":[{"type":"text","text":"History checkpoint 21: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":122,"time":1787472000122} +{"type":"step/start","data":{"turn":21,"step":1},"seq":123,"time":1787472000123} +{"type":"assistant/message","data":{"turn":21,"step":1,"message":{"id":"preview-assistant-21","role":"assistant","content":[{"type":"text","text":"Checkpoint 21 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":124,"time":1787472000124} +{"type":"step/end","data":{"turn":21,"step":1},"seq":125,"time":1787472000125} +{"type":"turn/end","data":{"turn":21,"reason":{"kind":"completed"}},"seq":126,"time":1787472000126} +{"type":"turn/start","data":{"turn":22},"seq":127,"time":1787472000127} +{"type":"user/message","data":{"id":"preview-user-22","role":"user","content":[{"type":"text","text":"History checkpoint 22: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":128,"time":1787472000128} +{"type":"step/start","data":{"turn":22,"step":1},"seq":129,"time":1787472000129} +{"type":"assistant/message","data":{"turn":22,"step":1,"message":{"id":"preview-assistant-22","role":"assistant","content":[{"type":"text","text":"Checkpoint 22 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":130,"time":1787472000130} +{"type":"step/end","data":{"turn":22,"step":1},"seq":131,"time":1787472000131} +{"type":"turn/end","data":{"turn":22,"reason":{"kind":"completed"}},"seq":132,"time":1787472000132} +{"type":"turn/start","data":{"turn":23},"seq":133,"time":1787472000133} +{"type":"user/message","data":{"id":"preview-user-23","role":"user","content":[{"type":"text","text":"History checkpoint 23: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":134,"time":1787472000134} +{"type":"step/start","data":{"turn":23,"step":1},"seq":135,"time":1787472000135} +{"type":"assistant/message","data":{"turn":23,"step":1,"message":{"id":"preview-assistant-23","role":"assistant","content":[{"type":"text","text":"Checkpoint 23 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":136,"time":1787472000136} +{"type":"step/end","data":{"turn":23,"step":1},"seq":137,"time":1787472000137} +{"type":"turn/end","data":{"turn":23,"reason":{"kind":"completed"}},"seq":138,"time":1787472000138} +{"type":"turn/start","data":{"turn":24},"seq":139,"time":1787472000139} +{"type":"user/message","data":{"id":"preview-user-24","role":"user","content":[{"type":"text","text":"History checkpoint 24: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":140,"time":1787472000140} +{"type":"step/start","data":{"turn":24,"step":1},"seq":141,"time":1787472000141} +{"type":"assistant/message","data":{"turn":24,"step":1,"message":{"id":"preview-assistant-24","role":"assistant","content":[{"type":"text","text":"Checkpoint 24 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":142,"time":1787472000142} +{"type":"step/end","data":{"turn":24,"step":1},"seq":143,"time":1787472000143} +{"type":"turn/end","data":{"turn":24,"reason":{"kind":"completed"}},"seq":144,"time":1787472000144} +{"type":"turn/start","data":{"turn":25},"seq":145,"time":1787472000145} +{"type":"user/message","data":{"id":"preview-user-25","role":"user","content":[{"type":"text","text":"History checkpoint 25: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":146,"time":1787472000146} +{"type":"step/start","data":{"turn":25,"step":1},"seq":147,"time":1787472000147} +{"type":"assistant/message","data":{"turn":25,"step":1,"message":{"id":"preview-assistant-25","role":"assistant","content":[{"type":"text","text":"Checkpoint 25 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":148,"time":1787472000148} +{"type":"step/end","data":{"turn":25,"step":1},"seq":149,"time":1787472000149} +{"type":"turn/end","data":{"turn":25,"reason":{"kind":"completed"}},"seq":150,"time":1787472000150} +{"type":"turn/start","data":{"turn":26},"seq":151,"time":1787472000151} +{"type":"user/message","data":{"id":"preview-user-26","role":"user","content":[{"type":"text","text":"History checkpoint 26: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":152,"time":1787472000152} +{"type":"step/start","data":{"turn":26,"step":1},"seq":153,"time":1787472000153} +{"type":"assistant/message","data":{"turn":26,"step":1,"message":{"id":"preview-assistant-26","role":"assistant","content":[{"type":"text","text":"Checkpoint 26 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":154,"time":1787472000154} +{"type":"step/end","data":{"turn":26,"step":1},"seq":155,"time":1787472000155} +{"type":"turn/end","data":{"turn":26,"reason":{"kind":"completed"}},"seq":156,"time":1787472000156} +{"type":"turn/start","data":{"turn":27},"seq":157,"time":1787472000157} +{"type":"user/message","data":{"id":"preview-user-27","role":"user","content":[{"type":"text","text":"History checkpoint 27: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":158,"time":1787472000158} +{"type":"step/start","data":{"turn":27,"step":1},"seq":159,"time":1787472000159} +{"type":"assistant/message","data":{"turn":27,"step":1,"message":{"id":"preview-assistant-27","role":"assistant","content":[{"type":"text","text":"Checkpoint 27 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":160,"time":1787472000160} +{"type":"step/end","data":{"turn":27,"step":1},"seq":161,"time":1787472000161} +{"type":"turn/end","data":{"turn":27,"reason":{"kind":"completed"}},"seq":162,"time":1787472000162} +{"type":"turn/start","data":{"turn":28},"seq":163,"time":1787472000163} +{"type":"user/message","data":{"id":"preview-user-28","role":"user","content":[{"type":"text","text":"History checkpoint 28: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":164,"time":1787472000164} +{"type":"step/start","data":{"turn":28,"step":1},"seq":165,"time":1787472000165} +{"type":"assistant/message","data":{"turn":28,"step":1,"message":{"id":"preview-assistant-28","role":"assistant","content":[{"type":"text","text":"Checkpoint 28 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":166,"time":1787472000166} +{"type":"step/end","data":{"turn":28,"step":1},"seq":167,"time":1787472000167} +{"type":"turn/end","data":{"turn":28,"reason":{"kind":"completed"}},"seq":168,"time":1787472000168} +{"type":"session/end-seed","data":{},"seq":169,"time":1787472100000} +{"type":"turn/start","data":{"turn":29},"seq":170,"time":1787472100001} +{"type":"user/message","data":{"id":"preview-review-user","role":"user","content":[{"type":"text","text":"Review whether the preview fixture is isolated from future WebFS data."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":171,"time":1787472100002} +{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Review preview architecture"},"seq":172,"time":1787472100003} +{"type":"step/start","data":{"turn":29,"step":1},"seq":173,"time":1787472100004} +{"type":"assistant/message","data":{"turn":29,"step":1,"message":{"id":"preview-review-assistant","role":"assistant","content":[{"type":"text","text":"The bundled fixture is static image content; future WebFS state remains user-owned."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":174,"time":1787472100005} +{"type":"step/end","data":{"turn":29,"step":1},"seq":175,"time":1787472100006} +{"type":"turn/end","data":{"turn":29,"reason":{"kind":"completed"}},"seq":176,"time":1787472100007} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl new file mode 100644 index 0000000000..6353e854c4 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl @@ -0,0 +1,8 @@ +{"type":"session","version":0,"id":"preview-follow-up-builder","createdAt":1787472200000,"cwd":"/dsh/workspace","parentSession":"preview-showcase","origin":"subagent","delegationDepth":1,"agentPreset":"standard"} +{"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472200000} +{"type":"user/message","data":{"id":"preview-builder-user","role":"user","content":[{"type":"text","text":"Check that the Preview workspace can support follow-up tasks."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472200001} +{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Continue preview verification"},"seq":2,"time":1787472200002} +{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472200003} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-builder-assistant","role":"assistant","content":[{"type":"text","text":"This child is continuable and ready for another verification turn."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":4,"time":1787472200004} +{"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472200005} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":6,"time":1787472200006} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-showcase/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-showcase/session.jsonl new file mode 100644 index 0000000000..7b15d63fb3 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-showcase/session.jsonl @@ -0,0 +1,200 @@ +{"type":"session","version":0,"id":"preview-showcase","createdAt":1787472000000,"cwd":"/dsh/workspace","delegationDepth":0,"agentPreset":"standard"} +{"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472000000} +{"type":"user/message","data":{"id":"preview-user-01","role":"user","content":[{"type":"text","text":"History checkpoint 01: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472000001} +{"type":"session/title","data":{"title":"WebWorker Preview Showcase","messageSeqs":[],"source":{"kind":"user"}},"seq":2,"time":1787472000002} +{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472000003} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-assistant-01","role":"assistant","content":[{"type":"text","text":"Checkpoint 01 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":4,"time":1787472000004} +{"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472000005} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":6,"time":1787472000006} +{"type":"turn/start","data":{"turn":2},"seq":7,"time":1787472000007} +{"type":"user/message","data":{"id":"preview-user-02","role":"user","content":[{"type":"text","text":"History checkpoint 02: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":8,"time":1787472000008} +{"type":"step/start","data":{"turn":2,"step":1},"seq":9,"time":1787472000009} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"id":"preview-assistant-02","role":"assistant","content":[{"type":"text","text":"Checkpoint 02 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":10,"time":1787472000010} +{"type":"step/end","data":{"turn":2,"step":1},"seq":11,"time":1787472000011} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":12,"time":1787472000012} +{"type":"turn/start","data":{"turn":3},"seq":13,"time":1787472000013} +{"type":"user/message","data":{"id":"preview-user-03","role":"user","content":[{"type":"text","text":"History checkpoint 03: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":14,"time":1787472000014} +{"type":"step/start","data":{"turn":3,"step":1},"seq":15,"time":1787472000015} +{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"id":"preview-assistant-03","role":"assistant","content":[{"type":"text","text":"Checkpoint 03 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":16,"time":1787472000016} +{"type":"step/end","data":{"turn":3,"step":1},"seq":17,"time":1787472000017} +{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":18,"time":1787472000018} +{"type":"turn/start","data":{"turn":4},"seq":19,"time":1787472000019} +{"type":"user/message","data":{"id":"preview-user-04","role":"user","content":[{"type":"text","text":"History checkpoint 04: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":20,"time":1787472000020} +{"type":"step/start","data":{"turn":4,"step":1},"seq":21,"time":1787472000021} +{"type":"assistant/message","data":{"turn":4,"step":1,"message":{"id":"preview-assistant-04","role":"assistant","content":[{"type":"text","text":"Checkpoint 04 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":22,"time":1787472000022} +{"type":"step/end","data":{"turn":4,"step":1},"seq":23,"time":1787472000023} +{"type":"turn/end","data":{"turn":4,"reason":{"kind":"completed"}},"seq":24,"time":1787472000024} +{"type":"turn/start","data":{"turn":5},"seq":25,"time":1787472000025} +{"type":"user/message","data":{"id":"preview-user-05","role":"user","content":[{"type":"text","text":"History checkpoint 05: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":26,"time":1787472000026} +{"type":"step/start","data":{"turn":5,"step":1},"seq":27,"time":1787472000027} +{"type":"assistant/message","data":{"turn":5,"step":1,"message":{"id":"preview-assistant-05","role":"assistant","content":[{"type":"text","text":"Checkpoint 05 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":28,"time":1787472000028} +{"type":"step/end","data":{"turn":5,"step":1},"seq":29,"time":1787472000029} +{"type":"turn/end","data":{"turn":5,"reason":{"kind":"completed"}},"seq":30,"time":1787472000030} +{"type":"turn/start","data":{"turn":6},"seq":31,"time":1787472000031} +{"type":"user/message","data":{"id":"preview-user-06","role":"user","content":[{"type":"text","text":"History checkpoint 06: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":32,"time":1787472000032} +{"type":"step/start","data":{"turn":6,"step":1},"seq":33,"time":1787472000033} +{"type":"assistant/message","data":{"turn":6,"step":1,"message":{"id":"preview-assistant-06","role":"assistant","content":[{"type":"text","text":"Checkpoint 06 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":34,"time":1787472000034} +{"type":"step/end","data":{"turn":6,"step":1},"seq":35,"time":1787472000035} +{"type":"turn/end","data":{"turn":6,"reason":{"kind":"completed"}},"seq":36,"time":1787472000036} +{"type":"turn/start","data":{"turn":7},"seq":37,"time":1787472000037} +{"type":"user/message","data":{"id":"preview-user-07","role":"user","content":[{"type":"text","text":"History checkpoint 07: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":38,"time":1787472000038} +{"type":"step/start","data":{"turn":7,"step":1},"seq":39,"time":1787472000039} +{"type":"assistant/message","data":{"turn":7,"step":1,"message":{"id":"preview-assistant-07","role":"assistant","content":[{"type":"text","text":"Checkpoint 07 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":40,"time":1787472000040} +{"type":"step/end","data":{"turn":7,"step":1},"seq":41,"time":1787472000041} +{"type":"turn/end","data":{"turn":7,"reason":{"kind":"completed"}},"seq":42,"time":1787472000042} +{"type":"turn/start","data":{"turn":8},"seq":43,"time":1787472000043} +{"type":"user/message","data":{"id":"preview-user-08","role":"user","content":[{"type":"text","text":"History checkpoint 08: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":44,"time":1787472000044} +{"type":"step/start","data":{"turn":8,"step":1},"seq":45,"time":1787472000045} +{"type":"assistant/message","data":{"turn":8,"step":1,"message":{"id":"preview-assistant-08","role":"assistant","content":[{"type":"text","text":"Checkpoint 08 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":46,"time":1787472000046} +{"type":"step/end","data":{"turn":8,"step":1},"seq":47,"time":1787472000047} +{"type":"turn/end","data":{"turn":8,"reason":{"kind":"completed"}},"seq":48,"time":1787472000048} +{"type":"turn/start","data":{"turn":9},"seq":49,"time":1787472000049} +{"type":"user/message","data":{"id":"preview-user-09","role":"user","content":[{"type":"text","text":"History checkpoint 09: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":50,"time":1787472000050} +{"type":"step/start","data":{"turn":9,"step":1},"seq":51,"time":1787472000051} +{"type":"assistant/message","data":{"turn":9,"step":1,"message":{"id":"preview-assistant-09","role":"assistant","content":[{"type":"text","text":"Checkpoint 09 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":52,"time":1787472000052} +{"type":"step/end","data":{"turn":9,"step":1},"seq":53,"time":1787472000053} +{"type":"turn/end","data":{"turn":9,"reason":{"kind":"completed"}},"seq":54,"time":1787472000054} +{"type":"turn/start","data":{"turn":10},"seq":55,"time":1787472000055} +{"type":"user/message","data":{"id":"preview-user-10","role":"user","content":[{"type":"text","text":"History checkpoint 10: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":56,"time":1787472000056} +{"type":"step/start","data":{"turn":10,"step":1},"seq":57,"time":1787472000057} +{"type":"assistant/message","data":{"turn":10,"step":1,"message":{"id":"preview-assistant-10","role":"assistant","content":[{"type":"text","text":"Checkpoint 10 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":58,"time":1787472000058} +{"type":"step/end","data":{"turn":10,"step":1},"seq":59,"time":1787472000059} +{"type":"turn/end","data":{"turn":10,"reason":{"kind":"completed"}},"seq":60,"time":1787472000060} +{"type":"turn/start","data":{"turn":11},"seq":61,"time":1787472000061} +{"type":"user/message","data":{"id":"preview-user-11","role":"user","content":[{"type":"text","text":"History checkpoint 11: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":62,"time":1787472000062} +{"type":"step/start","data":{"turn":11,"step":1},"seq":63,"time":1787472000063} +{"type":"assistant/message","data":{"turn":11,"step":1,"message":{"id":"preview-assistant-11","role":"assistant","content":[{"type":"text","text":"Checkpoint 11 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":64,"time":1787472000064} +{"type":"step/end","data":{"turn":11,"step":1},"seq":65,"time":1787472000065} +{"type":"turn/end","data":{"turn":11,"reason":{"kind":"completed"}},"seq":66,"time":1787472000066} +{"type":"turn/start","data":{"turn":12},"seq":67,"time":1787472000067} +{"type":"user/message","data":{"id":"preview-user-12","role":"user","content":[{"type":"text","text":"History checkpoint 12: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":68,"time":1787472000068} +{"type":"step/start","data":{"turn":12,"step":1},"seq":69,"time":1787472000069} +{"type":"assistant/message","data":{"turn":12,"step":1,"message":{"id":"preview-assistant-12","role":"assistant","content":[{"type":"text","text":"Checkpoint 12 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":70,"time":1787472000070} +{"type":"step/end","data":{"turn":12,"step":1},"seq":71,"time":1787472000071} +{"type":"turn/end","data":{"turn":12,"reason":{"kind":"completed"}},"seq":72,"time":1787472000072} +{"type":"turn/start","data":{"turn":13},"seq":73,"time":1787472000073} +{"type":"user/message","data":{"id":"preview-user-13","role":"user","content":[{"type":"text","text":"History checkpoint 13: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":74,"time":1787472000074} +{"type":"step/start","data":{"turn":13,"step":1},"seq":75,"time":1787472000075} +{"type":"assistant/message","data":{"turn":13,"step":1,"message":{"id":"preview-assistant-13","role":"assistant","content":[{"type":"text","text":"Checkpoint 13 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":76,"time":1787472000076} +{"type":"step/end","data":{"turn":13,"step":1},"seq":77,"time":1787472000077} +{"type":"turn/end","data":{"turn":13,"reason":{"kind":"completed"}},"seq":78,"time":1787472000078} +{"type":"turn/start","data":{"turn":14},"seq":79,"time":1787472000079} +{"type":"user/message","data":{"id":"preview-user-14","role":"user","content":[{"type":"text","text":"History checkpoint 14: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":80,"time":1787472000080} +{"type":"step/start","data":{"turn":14,"step":1},"seq":81,"time":1787472000081} +{"type":"assistant/message","data":{"turn":14,"step":1,"message":{"id":"preview-assistant-14","role":"assistant","content":[{"type":"text","text":"Checkpoint 14 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":82,"time":1787472000082} +{"type":"step/end","data":{"turn":14,"step":1},"seq":83,"time":1787472000083} +{"type":"turn/end","data":{"turn":14,"reason":{"kind":"completed"}},"seq":84,"time":1787472000084} +{"type":"turn/start","data":{"turn":15},"seq":85,"time":1787472000085} +{"type":"user/message","data":{"id":"preview-user-15","role":"user","content":[{"type":"text","text":"History checkpoint 15: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":86,"time":1787472000086} +{"type":"step/start","data":{"turn":15,"step":1},"seq":87,"time":1787472000087} +{"type":"assistant/message","data":{"turn":15,"step":1,"message":{"id":"preview-assistant-15","role":"assistant","content":[{"type":"text","text":"Checkpoint 15 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":88,"time":1787472000088} +{"type":"step/end","data":{"turn":15,"step":1},"seq":89,"time":1787472000089} +{"type":"turn/end","data":{"turn":15,"reason":{"kind":"completed"}},"seq":90,"time":1787472000090} +{"type":"turn/start","data":{"turn":16},"seq":91,"time":1787472000091} +{"type":"user/message","data":{"id":"preview-user-16","role":"user","content":[{"type":"text","text":"History checkpoint 16: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":92,"time":1787472000092} +{"type":"step/start","data":{"turn":16,"step":1},"seq":93,"time":1787472000093} +{"type":"assistant/message","data":{"turn":16,"step":1,"message":{"id":"preview-assistant-16","role":"assistant","content":[{"type":"text","text":"Checkpoint 16 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":94,"time":1787472000094} +{"type":"step/end","data":{"turn":16,"step":1},"seq":95,"time":1787472000095} +{"type":"turn/end","data":{"turn":16,"reason":{"kind":"completed"}},"seq":96,"time":1787472000096} +{"type":"turn/start","data":{"turn":17},"seq":97,"time":1787472000097} +{"type":"user/message","data":{"id":"preview-user-17","role":"user","content":[{"type":"text","text":"History checkpoint 17: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":98,"time":1787472000098} +{"type":"step/start","data":{"turn":17,"step":1},"seq":99,"time":1787472000099} +{"type":"assistant/message","data":{"turn":17,"step":1,"message":{"id":"preview-assistant-17","role":"assistant","content":[{"type":"text","text":"Checkpoint 17 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":100,"time":1787472000100} +{"type":"step/end","data":{"turn":17,"step":1},"seq":101,"time":1787472000101} +{"type":"turn/end","data":{"turn":17,"reason":{"kind":"completed"}},"seq":102,"time":1787472000102} +{"type":"turn/start","data":{"turn":18},"seq":103,"time":1787472000103} +{"type":"user/message","data":{"id":"preview-user-18","role":"user","content":[{"type":"text","text":"History checkpoint 18: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":104,"time":1787472000104} +{"type":"step/start","data":{"turn":18,"step":1},"seq":105,"time":1787472000105} +{"type":"assistant/message","data":{"turn":18,"step":1,"message":{"id":"preview-assistant-18","role":"assistant","content":[{"type":"text","text":"Checkpoint 18 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":106,"time":1787472000106} +{"type":"step/end","data":{"turn":18,"step":1},"seq":107,"time":1787472000107} +{"type":"turn/end","data":{"turn":18,"reason":{"kind":"completed"}},"seq":108,"time":1787472000108} +{"type":"turn/start","data":{"turn":19},"seq":109,"time":1787472000109} +{"type":"user/message","data":{"id":"preview-user-19","role":"user","content":[{"type":"text","text":"History checkpoint 19: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":110,"time":1787472000110} +{"type":"step/start","data":{"turn":19,"step":1},"seq":111,"time":1787472000111} +{"type":"assistant/message","data":{"turn":19,"step":1,"message":{"id":"preview-assistant-19","role":"assistant","content":[{"type":"text","text":"Checkpoint 19 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":112,"time":1787472000112} +{"type":"step/end","data":{"turn":19,"step":1},"seq":113,"time":1787472000113} +{"type":"turn/end","data":{"turn":19,"reason":{"kind":"completed"}},"seq":114,"time":1787472000114} +{"type":"turn/start","data":{"turn":20},"seq":115,"time":1787472000115} +{"type":"user/message","data":{"id":"preview-user-20","role":"user","content":[{"type":"text","text":"History checkpoint 20: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":116,"time":1787472000116} +{"type":"step/start","data":{"turn":20,"step":1},"seq":117,"time":1787472000117} +{"type":"assistant/message","data":{"turn":20,"step":1,"message":{"id":"preview-assistant-20","role":"assistant","content":[{"type":"text","text":"Checkpoint 20 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":118,"time":1787472000118} +{"type":"step/end","data":{"turn":20,"step":1},"seq":119,"time":1787472000119} +{"type":"turn/end","data":{"turn":20,"reason":{"kind":"completed"}},"seq":120,"time":1787472000120} +{"type":"turn/start","data":{"turn":21},"seq":121,"time":1787472000121} +{"type":"user/message","data":{"id":"preview-user-21","role":"user","content":[{"type":"text","text":"History checkpoint 21: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":122,"time":1787472000122} +{"type":"step/start","data":{"turn":21,"step":1},"seq":123,"time":1787472000123} +{"type":"assistant/message","data":{"turn":21,"step":1,"message":{"id":"preview-assistant-21","role":"assistant","content":[{"type":"text","text":"Checkpoint 21 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":124,"time":1787472000124} +{"type":"step/end","data":{"turn":21,"step":1},"seq":125,"time":1787472000125} +{"type":"turn/end","data":{"turn":21,"reason":{"kind":"completed"}},"seq":126,"time":1787472000126} +{"type":"turn/start","data":{"turn":22},"seq":127,"time":1787472000127} +{"type":"user/message","data":{"id":"preview-user-22","role":"user","content":[{"type":"text","text":"History checkpoint 22: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":128,"time":1787472000128} +{"type":"step/start","data":{"turn":22,"step":1},"seq":129,"time":1787472000129} +{"type":"assistant/message","data":{"turn":22,"step":1,"message":{"id":"preview-assistant-22","role":"assistant","content":[{"type":"text","text":"Checkpoint 22 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":130,"time":1787472000130} +{"type":"step/end","data":{"turn":22,"step":1},"seq":131,"time":1787472000131} +{"type":"turn/end","data":{"turn":22,"reason":{"kind":"completed"}},"seq":132,"time":1787472000132} +{"type":"turn/start","data":{"turn":23},"seq":133,"time":1787472000133} +{"type":"user/message","data":{"id":"preview-user-23","role":"user","content":[{"type":"text","text":"History checkpoint 23: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":134,"time":1787472000134} +{"type":"step/start","data":{"turn":23,"step":1},"seq":135,"time":1787472000135} +{"type":"assistant/message","data":{"turn":23,"step":1,"message":{"id":"preview-assistant-23","role":"assistant","content":[{"type":"text","text":"Checkpoint 23 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":136,"time":1787472000136} +{"type":"step/end","data":{"turn":23,"step":1},"seq":137,"time":1787472000137} +{"type":"turn/end","data":{"turn":23,"reason":{"kind":"completed"}},"seq":138,"time":1787472000138} +{"type":"turn/start","data":{"turn":24},"seq":139,"time":1787472000139} +{"type":"user/message","data":{"id":"preview-user-24","role":"user","content":[{"type":"text","text":"History checkpoint 24: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":140,"time":1787472000140} +{"type":"step/start","data":{"turn":24,"step":1},"seq":141,"time":1787472000141} +{"type":"assistant/message","data":{"turn":24,"step":1,"message":{"id":"preview-assistant-24","role":"assistant","content":[{"type":"text","text":"Checkpoint 24 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":142,"time":1787472000142} +{"type":"step/end","data":{"turn":24,"step":1},"seq":143,"time":1787472000143} +{"type":"turn/end","data":{"turn":24,"reason":{"kind":"completed"}},"seq":144,"time":1787472000144} +{"type":"turn/start","data":{"turn":25},"seq":145,"time":1787472000145} +{"type":"user/message","data":{"id":"preview-user-25","role":"user","content":[{"type":"text","text":"History checkpoint 25: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":146,"time":1787472000146} +{"type":"step/start","data":{"turn":25,"step":1},"seq":147,"time":1787472000147} +{"type":"assistant/message","data":{"turn":25,"step":1,"message":{"id":"preview-assistant-25","role":"assistant","content":[{"type":"text","text":"Checkpoint 25 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":148,"time":1787472000148} +{"type":"step/end","data":{"turn":25,"step":1},"seq":149,"time":1787472000149} +{"type":"turn/end","data":{"turn":25,"reason":{"kind":"completed"}},"seq":150,"time":1787472000150} +{"type":"turn/start","data":{"turn":26},"seq":151,"time":1787472000151} +{"type":"user/message","data":{"id":"preview-user-26","role":"user","content":[{"type":"text","text":"History checkpoint 26: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":152,"time":1787472000152} +{"type":"step/start","data":{"turn":26,"step":1},"seq":153,"time":1787472000153} +{"type":"assistant/message","data":{"turn":26,"step":1,"message":{"id":"preview-assistant-26","role":"assistant","content":[{"type":"text","text":"Checkpoint 26 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":154,"time":1787472000154} +{"type":"step/end","data":{"turn":26,"step":1},"seq":155,"time":1787472000155} +{"type":"turn/end","data":{"turn":26,"reason":{"kind":"completed"}},"seq":156,"time":1787472000156} +{"type":"turn/start","data":{"turn":27},"seq":157,"time":1787472000157} +{"type":"user/message","data":{"id":"preview-user-27","role":"user","content":[{"type":"text","text":"History checkpoint 27: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":158,"time":1787472000158} +{"type":"step/start","data":{"turn":27,"step":1},"seq":159,"time":1787472000159} +{"type":"assistant/message","data":{"turn":27,"step":1,"message":{"id":"preview-assistant-27","role":"assistant","content":[{"type":"text","text":"Checkpoint 27 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":160,"time":1787472000160} +{"type":"step/end","data":{"turn":27,"step":1},"seq":161,"time":1787472000161} +{"type":"turn/end","data":{"turn":27,"reason":{"kind":"completed"}},"seq":162,"time":1787472000162} +{"type":"turn/start","data":{"turn":28},"seq":163,"time":1787472000163} +{"type":"user/message","data":{"id":"preview-user-28","role":"user","content":[{"type":"text","text":"History checkpoint 28: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":164,"time":1787472000164} +{"type":"step/start","data":{"turn":28,"step":1},"seq":165,"time":1787472000165} +{"type":"assistant/message","data":{"turn":28,"step":1,"message":{"id":"preview-assistant-28","role":"assistant","content":[{"type":"text","text":"Checkpoint 28 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":166,"time":1787472000166} +{"type":"step/end","data":{"turn":28,"step":1},"seq":167,"time":1787472000167} +{"type":"turn/end","data":{"turn":28,"reason":{"kind":"completed"}},"seq":168,"time":1787472000168} +{"type":"turn/start","data":{"turn":29},"seq":169,"time":1787472000169} +{"type":"user/message","data":{"id":"preview-gallery-user","role":"user","content":[{"type":"text","text":"Show the seeded workspace, tool cards, subagents, and pagination in one tour."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":170,"time":1787472000170} +{"type":"step/start","data":{"turn":29,"step":1},"seq":171,"time":1787472000171} +{"type":"assistant/message","data":{"turn":29,"step":1,"message":{"id":"preview-gallery-tools","role":"assistant","content":[{"type":"reasoning","text":"I will inspect the deterministic workspace and collect each preview surface."},{"type":"tool-call","id":"preview-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"},{"type":"tool-call","id":"preview-write","name":"write","arguments":"{\"file_path\":\"src/preview.ts\",\"content\":\"export const previewStatus = 'ready'\\n\\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\\n\"}"},{"type":"tool-call","id":"preview-bash","name":"bash","arguments":"{\"command\":\"printf 'preview ready\\\\n'\",\"description\":\"Print the preview readiness marker\"}"},{"type":"tool-call","id":"preview-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"},{"type":"tool-call","id":"preview-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"},{"type":"tool-call","id":"preview-web-search","name":"web_search","arguments":"{\"queries\":[\"Web Worker filesystem compatibility\"]}"},{"type":"tool-call","id":"preview-todo","name":"todo_write","arguments":"{\"todos\":[{\"content\":\"Inspect tool cards\",\"status\":\"completed\"},{\"content\":\"Open both subagents\",\"status\":\"completed\"},{\"content\":\"Load earlier history\",\"status\":\"in_progress\"}]}"},{"type":"tool-call","id":"preview-subagent","name":"subagent","arguments":"{\"description\":\"Continue preview verification\",\"prompt\":\"Check the remaining preview cases.\",\"run_in_background\":true}"},{"type":"tool-call","id":"preview-subagent-fork","name":"subagent_fork","arguments":"{\"description\":\"Review preview architecture\",\"prompt\":\"Review the fixture architecture.\",\"run_in_background\":false}"},{"type":"tool-call","id":"preview-failure","name":"read","arguments":"{\"file_path\":\"missing.txt\"}"}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":172,"time":1787472000172} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"},"seq":173,"time":1787472000173} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-read-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-read","content":[{"type":"text","text":"PREVIEW.md\nfile\n\n1: # Preview Workspace\n2: \n3: This deterministic workspace is bundled with the browser-only preview.\n4: \n5: - `src/preview.ts` is the file changed by the example write result.\n6: - `data/tasks.json` mirrors the completed preview checklist.\n7: - `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing.\n8: \n9: Refresh the preview to restore these image bytes.\n\n(End of file - total 9 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"preview-read"}},"meta":{"path":"PREVIEW.md","offset":1,"lines":[{"number":1,"text":"# Preview Workspace"},{"number":2,"text":""},{"number":3,"text":"This deterministic workspace is bundled with the browser-only preview."},{"number":4,"text":""},{"number":5,"text":"- `src/preview.ts` is the file changed by the example write result."},{"number":6,"text":"- `data/tasks.json` mirrors the completed preview checklist."},{"number":7,"text":"- `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing."},{"number":8,"text":""},{"number":9,"text":"Refresh the preview to restore these image bytes."}],"totalLines":9,"lang":"md"}},"surfaceOp":"append","seq":174,"time":1787472000174} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-write","name":"write","arguments":"{\"file_path\":\"src/preview.ts\",\"content\":\"export const previewStatus = 'ready'\\n\\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\\n\"}"},"seq":175,"time":1787472000175} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-write-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-write","content":[{"type":"text","text":"src/preview.ts\nfile\n\nUpdated file\n"}],"isError":false}],"source":{"kind":"tool","callId":"preview-write"}},"meta":{"diffs":[{"path":"src/preview.ts","oldText":"export const previewStatus = 'draft'\n","newText":"export const previewStatus = 'ready'\n\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\n"}]}},"surfaceOp":"append","seq":176,"time":1787472000176} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-bash","name":"bash","arguments":"{\"command\":\"printf 'preview ready\\\\n'\",\"description\":\"Print the preview readiness marker\"}"},"seq":177,"time":1787472000177} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-bash-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-bash","content":[{"type":"text","text":"preview ready\n"}],"isError":false}],"source":{"kind":"tool","callId":"preview-bash"}}},"surfaceOp":"append","seq":178,"time":1787472000178} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"},"seq":179,"time":1787472000179} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-glob-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-glob","content":[{"type":"text","text":"PREVIEW.md\ndata/tasks.json\nsrc/preview.ts"}],"isError":false}],"source":{"kind":"tool","callId":"preview-glob"}},"meta":{"shape":"paths","paths":["PREVIEW.md","data/tasks.json","src/preview.ts"],"truncated":false,"total":3}},"surfaceOp":"append","seq":180,"time":1787472000180} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"},"seq":181,"time":1787472000181} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-grep-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-grep","content":[{"type":"text","text":"PREVIEW.md:3:This deterministic workspace is bundled with the browser-only preview.\nsrc/preview.ts:1:export const previewStatus = 'ready'"}],"isError":false}],"source":{"kind":"tool","callId":"preview-grep"}},"meta":{"shape":"matches","files":[{"path":"PREVIEW.md","matches":[{"lineNumber":3,"line":"This deterministic workspace is bundled with the browser-only preview."}]},{"path":"src/preview.ts","matches":[{"lineNumber":1,"line":"export const previewStatus = 'ready'"}]}],"truncated":false,"total":2}},"surfaceOp":"append","seq":182,"time":1787472000182} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-web-search","name":"web_search","arguments":"{\"queries\":[\"Web Worker filesystem compatibility\"]}"},"seq":183,"time":1787472000183} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-web-search-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-web-search","content":[{"type":"text","text":"Browser workers can host deterministic in-memory filesystems.\n\nSources:\n1. MDN Web Workers API — https://developer.mozilla.org/docs/Web/API/Web_Workers_API"}],"isError":false}],"source":{"kind":"tool","callId":"preview-web-search"}},"meta":{"sources":[{"url":"https://developer.mozilla.org/docs/Web/API/Web_Workers_API","title":"Web Workers API","snippet":"Web Workers run scripts in background threads."}],"truncated":false,"answer":"Browser workers can host deterministic in-memory filesystems."}},"surfaceOp":"append","seq":184,"time":1787472000184} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-todo","name":"todo_write","arguments":"{\"todos\":[{\"content\":\"Inspect tool cards\",\"status\":\"completed\"},{\"content\":\"Open both subagents\",\"status\":\"completed\"},{\"content\":\"Load earlier history\",\"status\":\"in_progress\"}]}"},"seq":185,"time":1787472000185} +{"type":"todo/write","data":{"todos":[{"content":"Inspect tool cards","status":"completed"},{"content":"Open both subagents","status":"completed"},{"content":"Load earlier history","status":"in_progress"}]},"seq":186,"time":1787472000186} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-todo-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-todo","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 2 completed."}],"isError":false}],"source":{"kind":"tool","callId":"preview-todo"}}},"surfaceOp":"append","seq":187,"time":1787472000187} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-subagent","name":"subagent","arguments":"{\"description\":\"Continue preview verification\",\"prompt\":\"Check the remaining preview cases.\",\"run_in_background\":true}"},"seq":188,"time":1787472000188} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-subagent-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-subagent","content":[{"type":"text","text":"started subagent preview-follow-up-builder"}],"isError":false}],"source":{"kind":"tool","callId":"preview-subagent"}}},"surfaceOp":"append","seq":189,"time":1787472000189} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-subagent-fork","name":"subagent_fork","arguments":"{\"description\":\"Review preview architecture\",\"prompt\":\"Review the fixture architecture.\",\"run_in_background\":false}"},"seq":190,"time":1787472000190} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-subagent-fork-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-subagent-fork","content":[{"type":"text","text":"The preview fixture remains separate from user-owned WebFS data."}],"isError":false}],"source":{"kind":"tool","callId":"preview-subagent-fork"}}},"surfaceOp":"append","seq":191,"time":1787472000191} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-failure","name":"read","arguments":"{\"file_path\":\"missing.txt\"}"},"seq":192,"time":1787472000192} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-failure-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-failure","content":[{"type":"text","text":"Error: ENOENT: no such file, open missing.txt"}],"isError":true}],"source":{"kind":"tool","callId":"preview-failure"}},"error":{"name":"FsError","code":"ENOENT"}},"surfaceOp":"append","seq":193,"time":1787472000193} +{"type":"step/end","data":{"turn":29,"step":1},"seq":194,"time":1787472000194} +{"type":"step/start","data":{"turn":29,"step":2},"seq":195,"time":1787472000195} +{"type":"assistant/message","data":{"turn":29,"step":2,"message":{"id":"preview-gallery-final","role":"assistant","content":[{"type":"text","text":"## Preview tour complete\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":196,"time":1787472000196} +{"type":"step/end","data":{"turn":29,"step":2},"seq":197,"time":1787472000197} +{"type":"turn/end","data":{"turn":29,"reason":{"kind":"completed"}},"seq":198,"time":1787472000198} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json new file mode 100644 index 0000000000..9248dd7ae7 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json @@ -0,0 +1,24 @@ +{ + "unit": { + "name": "session_projcache", + "version": 3 + }, + "global": null, + "tables": { + "sessions": { + "preview-showcase": { + "identity": { + "createdAt": 1787472000000, + "cwd": "/dsh/workspace" + }, + "rows": { + "title": { + "ver": 1, + "seq": 198, + "val": "WebWorker Preview Showcase" + } + } + } + } + } +} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/.agents/skills/preview-tour/SKILL.md b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/.agents/skills/preview-tour/SKILL.md new file mode 100644 index 0000000000..4d4f875565 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/.agents/skills/preview-tour/SKILL.md @@ -0,0 +1,8 @@ +--- +name: preview-tour +description: Inspect the bundled Preview workspace and its deterministic Session examples. +--- + +# Preview tour + +Read the workspace files, inspect the tool gallery, open both subagent histories, and load the earlier conversation page. diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/PREVIEW.md b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/PREVIEW.md new file mode 100644 index 0000000000..ae96a96efe --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/PREVIEW.md @@ -0,0 +1,9 @@ +# Preview Workspace + +This deterministic workspace is bundled with the browser-only preview. + +- `src/preview.ts` is the file changed by the example write result. +- `data/tasks.json` mirrors the completed preview checklist. +- `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing. + +Refresh the preview to restore these image bytes. diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/data/tasks.json b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/data/tasks.json new file mode 100644 index 0000000000..65ad487ce7 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/data/tasks.json @@ -0,0 +1,17 @@ +{ + "title": "Preview verification", + "tasks": [ + { + "name": "Inspect tool cards", + "status": "completed" + }, + { + "name": "Open both subagents", + "status": "completed" + }, + { + "name": "Load earlier history", + "status": "completed" + } + ] +} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/src/preview.ts b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/src/preview.ts new file mode 100644 index 0000000000..1494e6ea20 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/src/preview.ts @@ -0,0 +1,3 @@ +export const previewStatus = 'ready' + +export const previewFeatures = ['tools', 'subagents', 'pagination'] as const diff --git a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts index 6a5826a19a..e61693e479 100644 --- a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts @@ -108,10 +108,9 @@ it('reports that a synchronous run cannot happen, without throwing at the probe' }) expect(spawnSync(launcherPath(), ['--ro', '/', '--', 'echo', 'x']).error?.message) .toContain('commands run asynchronously') - expect(spawnSync(launcherPath(), ['--probe', '--'])).toMatchObject({ - status: LAUNCHER_FAILURE_EXIT, - stderr: expect.any(Buffer), - }) + const failedProbe = spawnSync(launcherPath(), ['--probe', '--']) + expect(failedProbe.status).toBe(LAUNCHER_FAILURE_EXIT) + expect(Buffer.isBuffer(failedProbe.stderr)).toBe(true) }) it('keeps the native Landlock package API and CLI failure contract', async () => { diff --git a/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts b/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts index 47aee5ec6b..efde651e9c 100644 --- a/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest' import { packTar, parseTar } from '../../src/storage/tar.ts' -import { loadVfsImage } from '../../src/storage/memory.ts' +import { loadVfsImage, loadVfsOverlay } from '../../src/storage/memory.ts' const encoder = new TextEncoder() @@ -38,4 +38,21 @@ describe('tar codec', () => { expect(vfs.existsSync('/dsh/workspace')).toBe(true) expect(vfs.existsSync('/dsh/absent')).toBe(false) }) + + it('applies ordered data overlays without exposing runtime paths', () => { + const vfs = loadVfsImage(packTar({ + 'config/cordis.yml': encoder.encode('- id: subject\n'), + 'workspace/status.txt': encoder.encode('base'), + }), '/dsh') + loadVfsOverlay(packTar({ + 'workspace/status.txt': encoder.encode('fixture'), + 'home/sessions/example/session.jsonl': encoder.encode('{}\n'), + }), '/dsh', vfs) + expect(vfs.readFileSync('/dsh/workspace/status.txt', 'utf8')).toBe('fixture') + expect(vfs.readFileSync('/dsh/home/sessions/example/session.jsonl', 'utf8')).toBe('{}\n') + expect(() => loadVfsOverlay(packTar({ + 'config/cordis.yml': encoder.encode('replaced'), + }), '/dsh', vfs)).toThrow(/overlay entry must stay under home\/ or workspace/) + expect(vfs.readFileSync('/dsh/config/cordis.yml', 'utf8')).toBe('- id: subject\n') + }) }) diff --git a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts new file mode 100644 index 0000000000..9519962309 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseInboundFrame } from '../../src/transport/frames.ts' + +describe('tunnel init frame', () => { + it('retains the selected overlay order', () => { + expect(parseInboundFrame({ + t: 'init', + image: 'base.tar.gz', + overlays: ['workspace.tar.gz', 'session.tar.gz'], + })).toEqual({ + t: 'init', + image: 'base.tar.gz', + overlays: ['workspace.tar.gz', 'session.tar.gz'], + }) + }) + + it('rejects a missing or non-string overlay list', () => { + expect(() => parseInboundFrame({ t: 'init', image: 'base.tar.gz' })).toThrow(/array of string overlay urls/) + expect(() => parseInboundFrame({ t: 'init', image: 'base.tar.gz', overlays: [1] })) + .toThrow(/array of string overlay urls/) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index ceacba25df..64e550dc48 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -54,6 +54,27 @@ function stubWorker(): { } } +// The opening frame preserves overlay order for deterministic pre-boot mounts. +{ + const { worker, sent } = stubWorker() + const tunnel = new WorkerTunnel(worker) + tunnel.init('https://preview.test/base.tar.gz', [ + 'https://preview.test/first.tar.gz', + 'https://preview.test/second.tar.gz', + ]) + check('the init frame carries ordered overlays', sent[0], { + t: 'init', + image: 'https://preview.test/base.tar.gz', + overlays: ['https://preview.test/first.tar.gz', 'https://preview.test/second.tar.gz'], + }) + + const direct = stubWorker() + new WorkerTunnel(direct.worker).init('https://preview.test/base.tar.gz') + check('the direct init path defaults to no overlays', direct.sent[0], { + t: 'init', image: 'https://preview.test/base.tar.gz', overlays: [], + }) +} + // A normal reply resolves and says nothing on the console. { const { worker, sent, deliver } = stubWorker() diff --git a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts new file mode 100644 index 0000000000..561cf04699 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts @@ -0,0 +1,118 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { scanLog } from '@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts' +import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { + buildVfsExampleFiles, + VFS_EXAMPLE_OLDEST_MESSAGE, + VFS_EXAMPLE_ROOT, + VFS_EXAMPLE_SESSION_IDS, + VFS_EXAMPLE_TAIL_MESSAGE, + VFS_EXAMPLE_TITLE, +} from './vfs-example-fixture.ts' + +function filesUnder(root: string): string[] { + const files: string[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) visit(path) + else if (entry.isFile()) files.push(relative(root, path).replaceAll('\\', '/')) + } + } + visit(root) + return files.sort() +} + +function readSession(id: string): ReturnType { + return scanLog(readFileSync( + join(VFS_EXAMPLE_ROOT, 'home/sessions/--dsh-workspace--', id, 'session.jsonl'), + )) +} + +function textOf(event: SessionEvent): string { + if (event.type === 'user/message') { + return event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') + } + if (event.type === 'assistant/message') { + return event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') + } + return '' +} + +describe('WebWorker preview VFS example', () => { + it('matches its deterministic source byte for byte', () => { + const expected = buildVfsExampleFiles() + expect(filesUnder(VFS_EXAMPLE_ROOT)).toEqual([...expected.keys()].sort()) + for (const [path, content] of expected) { + expect(readFileSync(join(VFS_EXAMPLE_ROOT, path), 'utf8'), path).toBe(content) + } + }) + + it('seeds the cold-list title cache against the main log identity', () => { + const cache = JSON.parse(readFileSync( + join(VFS_EXAMPLE_ROOT, 'home/storages/session_projcache.json'), + 'utf8', + )) as { + unit: { name: string; version: number } + tables: { sessions: Record } + } + expect(cache.unit).toEqual({ name: 'session_projcache', version: 3 }) + expect(cache.tables.sessions[VFS_EXAMPLE_SESSION_IDS.main]).toMatchObject({ + identity: { createdAt: 1_787_472_000_000, cwd: '/dsh/workspace' }, + rows: { title: { ver: 1, val: VFS_EXAMPLE_TITLE } }, + }) + }) + + it('restores the main production log with paging and tool coverage', () => { + const { meta, events } = readSession(VFS_EXAMPLE_SESSION_IDS.main) + expect(meta).toMatchObject({ + id: VFS_EXAMPLE_SESSION_IDS.main, + cwd: '/dsh/workspace', + delegationDepth: 0, + agentPreset: 'standard', + }) + expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index)) + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + expect(() => Session.fromRestore(SessionId(meta.id), events, meta)).not.toThrow() + + const messages = events.filter(event => + (event.type === 'user/message' || event.type === 'assistant/message') && event.surfaceOp === 'append') + expect(messages.length).toBeGreaterThan(50) + expect(messages.some(event => textOf(event).includes(VFS_EXAMPLE_OLDEST_MESSAGE))).toBe(true) + expect(messages.some(event => textOf(event).includes(VFS_EXAMPLE_TAIL_MESSAGE))).toBe(true) + expect(events.some(event => event.type === 'session/title' + && (event.data as { title?: unknown }).title === VFS_EXAMPLE_TITLE)).toBe(true) + + const tools = events.flatMap(event => event.type === 'tool/call' ? [event.data.name] : []) + expect(new Set(tools)).toEqual(new Set([ + 'read', 'write', 'bash', 'glob', 'grep', 'web_search', 'todo_write', 'subagent', 'subagent_fork', + ])) + expect(events.some(event => event.type === 'todo/write')).toBe(true) + expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError === true)).toBe(true) + }) + + it('restores one-shot and continuable child Sessions with durable descriptors', () => { + const expected = [ + [VFS_EXAMPLE_SESSION_IDS.oneShot, 'one-shot'], + [VFS_EXAMPLE_SESSION_IDS.continuable, 'continuable'], + ] as const + for (const [id, mode] of expected) { + const { meta, events } = readSession(id) + expect(meta).toMatchObject({ + id, + cwd: '/dsh/workspace', + parentSession: VFS_EXAMPLE_SESSION_IDS.main, + origin: 'subagent', + delegationDepth: 1, + agentPreset: 'standard', + }) + expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index)) + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + expect(foldSubagentDescriptor(events.slice(meta.seedLength ?? 0))).toMatchObject({ mode }) + expect(() => Session.fromRestore(SessionId(meta.id), events, meta)).not.toThrow() + } + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts new file mode 100644 index 0000000000..dbc151eb86 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts @@ -0,0 +1,432 @@ +/** Deterministic source for the filesystem tree bundled into the WebWorker preview. */ + +import { fileURLToPath } from 'node:url' +import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { + eventLines, projectKey, toHeaderLine, +} from '@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' + +/** Root copied by the preview image's repository adapter. */ +export const VFS_EXAMPLE_ROOT = fileURLToPath(new URL('./fixtures/vfs-example', import.meta.url)) + +/** Durable ids used by browser assertions and subagent parent links. */ +export const VFS_EXAMPLE_SESSION_IDS = { + main: SessionId('preview-showcase'), + oneShot: SessionId('preview-architecture-review'), + continuable: SessionId('preview-follow-up-builder'), +} as const + +/** Stable title rendered in the root Session list. */ +export const VFS_EXAMPLE_TITLE = 'WebWorker Preview Showcase' + +/** Oldest prompt, intentionally outside the first 50-message history page. */ +export const VFS_EXAMPLE_OLDEST_MESSAGE = 'History checkpoint 01: verify deterministic preview state.' + +/** Settled tail marker used by browser acceptance and the demonstration GIF. */ +export const VFS_EXAMPLE_TAIL_MESSAGE = 'Preview tour complete' + +const WORKSPACE = '/dsh/workspace' +const CREATED_AT = 1_787_472_000_000 +const HISTORICAL_TURNS = 28 + +const PREVIEW_GUIDE = `# Preview Workspace + +This deterministic workspace is bundled with the browser-only preview. + +- \`src/preview.ts\` is the file changed by the example write result. +- \`data/tasks.json\` mirrors the completed preview checklist. +- \`.agents/skills/preview-tour/SKILL.md\` proves dot directories survive image packing. + +Refresh the preview to restore these image bytes. +` + +const PREVIEW_SOURCE_BEFORE = 'export const previewStatus = \'draft\'\n' + +const PREVIEW_SOURCE = `export const previewStatus = 'ready' + +export const previewFeatures = ['tools', 'subagents', 'pagination'] as const +` + +const TASKS = `${JSON.stringify({ + title: 'Preview verification', + tasks: [ + { name: 'Inspect tool cards', status: 'completed' }, + { name: 'Open both subagents', status: 'completed' }, + { name: 'Load earlier history', status: 'completed' }, + ], +}, null, 2)}\n` + +const SKILL = `--- +name: preview-tour +description: Inspect the bundled Preview workspace and its deterministic Session examples. +--- + +# Preview tour + +Read the workspace files, inspect the tool gallery, open both subagent histories, and load the earlier conversation page. +` + +interface EventDraft { + readonly type: string + readonly data: unknown + readonly surfaceOp?: 'append' + readonly sourceEventSeqs?: number[] + readonly ignorable?: true +} + +class EventLog { + readonly events: SessionEvent[] + private nextTime: number + + constructor(time: number, seed: readonly SessionEvent[] = []) { + this.events = seed.map(event => structuredClone(event)) + this.nextTime = Math.max(time, (this.events.at(-1)?.time ?? time - 1) + 1) + } + + add(draft: EventDraft): number { + const seq = this.events.length + this.events.push({ ...draft, seq, time: this.nextTime++ } as unknown as SessionEvent) + return seq + } +} + +function userMessage(id: string, text: string): EventDraft { + return { + type: 'user/message', + data: { + id, + role: 'user', + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + } +} + +function assistantMessage(id: string, turn: number, step: number, content: unknown[]): EventDraft { + return { + type: 'assistant/message', + data: { + turn, + step, + message: { + id, + role: 'assistant', + content, + source: { kind: 'model', provider: 'preview-fixture', model: 'deterministic' }, + }, + }, + sourceEventSeqs: [], + surfaceOp: 'append', + } +} + +interface GalleryCall { + readonly id: string + readonly name: string + readonly args: Record + readonly result: string + readonly meta?: unknown + readonly error?: { readonly name: string; readonly code: string } + readonly todos?: Array<{ readonly content: string; readonly status: 'pending' | 'in_progress' | 'completed' }> +} + +function readResult(): { text: string; meta: unknown } { + const lines = PREVIEW_GUIDE.trimEnd().split('\n').map((text, index) => ({ number: index + 1, text })) + return { + text: `PREVIEW.md\nfile\n\n${lines.map(line => `${String(line.number)}: ${line.text}`).join('\n')}\n\n(End of file - total ${String(lines.length)} lines)\n`, + meta: { path: 'PREVIEW.md', offset: 1, lines, totalLines: lines.length, lang: 'md' }, + } +} + +function galleryCalls(): GalleryCall[] { + const read = readResult() + return [ + { + id: 'preview-read', + name: 'read', + args: { file_path: 'PREVIEW.md' }, + result: read.text, + meta: read.meta, + }, + { + id: 'preview-write', + name: 'write', + args: { file_path: 'src/preview.ts', content: PREVIEW_SOURCE }, + result: 'src/preview.ts\nfile\n\nUpdated file\n', + meta: { diffs: [{ path: 'src/preview.ts', oldText: PREVIEW_SOURCE_BEFORE, newText: PREVIEW_SOURCE }] }, + }, + { + id: 'preview-bash', + name: 'bash', + args: { command: "printf 'preview ready\\n'", description: 'Print the preview readiness marker' }, + result: 'preview ready\n', + }, + { + id: 'preview-glob', + name: 'glob', + args: { pattern: '**/*', path: '.' }, + result: 'PREVIEW.md\ndata/tasks.json\nsrc/preview.ts', + meta: { + shape: 'paths', + paths: ['PREVIEW.md', 'data/tasks.json', 'src/preview.ts'], + truncated: false, + total: 3, + }, + }, + { + id: 'preview-grep', + name: 'grep', + args: { pattern: 'preview', path: '.', include: '*.{md,ts,json}' }, + result: 'PREVIEW.md:3:This deterministic workspace is bundled with the browser-only preview.\nsrc/preview.ts:1:export const previewStatus = \'ready\'', + meta: { + shape: 'matches', + files: [ + { path: 'PREVIEW.md', matches: [{ lineNumber: 3, line: 'This deterministic workspace is bundled with the browser-only preview.' }] }, + { path: 'src/preview.ts', matches: [{ lineNumber: 1, line: "export const previewStatus = 'ready'" }] }, + ], + truncated: false, + total: 2, + }, + }, + { + id: 'preview-web-search', + name: 'web_search', + args: { queries: ['Web Worker filesystem compatibility'] }, + result: 'Browser workers can host deterministic in-memory filesystems.\n\nSources:\n1. MDN Web Workers API — https://developer.mozilla.org/docs/Web/API/Web_Workers_API', + meta: { + sources: [{ + url: 'https://developer.mozilla.org/docs/Web/API/Web_Workers_API', + title: 'Web Workers API', + snippet: 'Web Workers run scripts in background threads.', + }], + truncated: false, + answer: 'Browser workers can host deterministic in-memory filesystems.', + }, + }, + { + id: 'preview-todo', + name: 'todo_write', + args: { + todos: [ + { content: 'Inspect tool cards', status: 'completed' }, + { content: 'Open both subagents', status: 'completed' }, + { content: 'Load earlier history', status: 'in_progress' }, + ], + }, + result: 'Updated todo list: 0 pending, 1 in progress, 2 completed.', + todos: [ + { content: 'Inspect tool cards', status: 'completed' }, + { content: 'Open both subagents', status: 'completed' }, + { content: 'Load earlier history', status: 'in_progress' }, + ], + }, + { + id: 'preview-subagent', + name: 'subagent', + args: { description: 'Continue preview verification', prompt: 'Check the remaining preview cases.', run_in_background: true }, + result: `started subagent ${VFS_EXAMPLE_SESSION_IDS.continuable}`, + }, + { + id: 'preview-subagent-fork', + name: 'subagent_fork', + args: { description: 'Review preview architecture', prompt: 'Review the fixture architecture.', run_in_background: false }, + result: 'The preview fixture remains separate from user-owned WebFS data.', + }, + { + id: 'preview-failure', + name: 'read', + args: { file_path: 'missing.txt' }, + result: 'Error: ENOENT: no such file, open missing.txt', + error: { name: 'FsError', code: 'ENOENT' }, + }, + ] +} + +function addClosedTextTurn(log: EventLog, turn: number): void { + const checkpoint = String(turn).padStart(2, '0') + log.add({ type: 'turn/start', data: { turn } }) + log.add(userMessage(`preview-user-${checkpoint}`, `History checkpoint ${checkpoint}: verify deterministic preview state.`)) + if (turn === 1) { + log.add({ + type: 'session/title', + data: { title: VFS_EXAMPLE_TITLE, messageSeqs: [], source: { kind: 'user' } }, + }) + } + log.add({ type: 'step/start', data: { turn, step: 1 } }) + log.add(assistantMessage( + `preview-assistant-${checkpoint}`, + turn, + 1, + [{ type: 'text', text: `Checkpoint ${checkpoint} is recorded.` }], + )) + log.add({ type: 'step/end', data: { turn, step: 1 } }) + log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) +} + +function mainLog(): { readonly events: SessionEvent[]; readonly forkSeedLength: number } { + const log = new EventLog(CREATED_AT) + for (let turn = 1; turn <= HISTORICAL_TURNS; turn++) addClosedTextTurn(log, turn) + const forkSeedLength = log.events.length + const turn = HISTORICAL_TURNS + 1 + const calls = galleryCalls() + + log.add({ type: 'turn/start', data: { turn } }) + log.add(userMessage('preview-gallery-user', 'Show the seeded workspace, tool cards, subagents, and pagination in one tour.')) + log.add({ type: 'step/start', data: { turn, step: 1 } }) + log.add(assistantMessage('preview-gallery-tools', turn, 1, [ + { type: 'reasoning', text: 'I will inspect the deterministic workspace and collect each preview surface.' }, + ...calls.map(call => ({ type: 'tool-call', id: call.id, name: call.name, arguments: JSON.stringify(call.args) })), + ])) + for (const call of calls) { + log.add({ + type: 'tool/call', + data: { turn, step: 1, callId: call.id, name: call.name, arguments: JSON.stringify(call.args) }, + }) + if (call.todos !== undefined) log.add({ type: 'todo/write', data: { todos: call.todos } }) + log.add({ + type: 'tool/result', + data: { + turn, + step: 1, + message: { + id: `${call.id}-result`, + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: call.id, + content: [{ type: 'text', text: call.result }], + isError: call.error !== undefined, + }], + source: { kind: 'tool', callId: call.id }, + }, + ...call.meta === undefined ? {} : { meta: call.meta }, + ...call.error === undefined ? {} : { error: call.error }, + }, + surfaceOp: 'append', + }) + } + log.add({ type: 'step/end', data: { turn, step: 1 } }) + log.add({ type: 'step/start', data: { turn, step: 2 } }) + log.add(assistantMessage('preview-gallery-final', turn, 2, [{ + type: 'text', + text: `## ${VFS_EXAMPLE_TAIL_MESSAGE}\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect.`, + }])) + log.add({ type: 'step/end', data: { turn, step: 2 } }) + log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + return { events: log.events, forkSeedLength } +} + +function oneShotLog(seed: readonly SessionEvent[]): SessionEvent[] { + const log = new EventLog(CREATED_AT + 100_000, seed) + log.add({ type: 'session/end-seed', data: {} }) + const turn = HISTORICAL_TURNS + 1 + log.add({ type: 'turn/start', data: { turn } }) + log.add(userMessage('preview-review-user', 'Review whether the preview fixture is isolated from future WebFS data.')) + log.add({ + type: 'subagent/descriptor', + data: snapshotSubagentDescriptor({ + mode: 'one-shot', provider: 'fork', label: 'Review preview architecture', + }), + }) + log.add({ type: 'step/start', data: { turn, step: 1 } }) + log.add(assistantMessage('preview-review-assistant', turn, 1, [{ + type: 'text', + text: 'The bundled fixture is static image content; future WebFS state remains user-owned.', + }])) + log.add({ type: 'step/end', data: { turn, step: 1 } }) + log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + return log.events +} + +function continuableLog(): SessionEvent[] { + const log = new EventLog(CREATED_AT + 200_000) + log.add({ type: 'turn/start', data: { turn: 1 } }) + log.add(userMessage('preview-builder-user', 'Check that the Preview workspace can support follow-up tasks.')) + log.add({ + type: 'subagent/descriptor', + data: snapshotSubagentDescriptor({ + mode: 'continuable', provider: 'spawn', label: 'Continue preview verification', + }), + }) + log.add({ type: 'step/start', data: { turn: 1, step: 1 } }) + log.add(assistantMessage('preview-builder-assistant', 1, 1, [{ + type: 'text', + text: 'This child is continuable and ready for another verification turn.', + }])) + log.add({ type: 'step/end', data: { turn: 1, step: 1 } }) + log.add({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }) + return log.events +} + +function header( + id: SessionHeader['id'], + createdAt: number, + child?: { readonly parentSession: SessionHeader['id']; readonly mode: 'one-shot' | 'continuable'; readonly seedLength?: number }, +): SessionHeader { + return { + version: 0, + id, + createdAt, + cwd: WORKSPACE, + delegationDepth: child === undefined ? 0 : 1, + agentPreset: 'standard', + ...child === undefined ? {} : { + parentSession: child.parentSession, + origin: 'subagent' as const, + ...child.seedLength === undefined ? {} : { seedLength: child.seedLength }, + }, + } +} + +function renderLog(meta: SessionHeader, events: readonly SessionEvent[]): string { + return `${JSON.stringify(toHeaderLine(meta))}\n${eventLines(events, true)}\n` +} + +/** Build every committed fixture file as repository-relative UTF-8 text. */ +export function buildVfsExampleFiles(): ReadonlyMap { + const main = mainLog() + const project = projectKey(WORKSPACE) + const sessionPath = (id: string): string => `home/sessions/${project}/${id}/session.jsonl` + const projectionCache = `${JSON.stringify({ + unit: { name: 'session_projcache', version: 3 }, + global: null, + tables: { + sessions: { + [VFS_EXAMPLE_SESSION_IDS.main]: { + identity: { createdAt: CREATED_AT, cwd: WORKSPACE }, + rows: { + title: { ver: 1, seq: main.events.at(-1)?.seq ?? -1, val: VFS_EXAMPLE_TITLE }, + }, + }, + }, + }, + }, null, 2)}\n` + return new Map([ + ['workspace/PREVIEW.md', PREVIEW_GUIDE], + ['workspace/src/preview.ts', PREVIEW_SOURCE], + ['workspace/data/tasks.json', TASKS], + ['workspace/.agents/skills/preview-tour/SKILL.md', SKILL], + ['home/storages/session_projcache.json', projectionCache], + [sessionPath(VFS_EXAMPLE_SESSION_IDS.main), renderLog( + header(VFS_EXAMPLE_SESSION_IDS.main, CREATED_AT), + main.events, + )], + [sessionPath(VFS_EXAMPLE_SESSION_IDS.oneShot), renderLog( + header(VFS_EXAMPLE_SESSION_IDS.oneShot, CREATED_AT + 100_000, { + parentSession: VFS_EXAMPLE_SESSION_IDS.main, + mode: 'one-shot', + seedLength: main.forkSeedLength, + }), + oneShotLog(main.events.slice(0, main.forkSeedLength)), + )], + [sessionPath(VFS_EXAMPLE_SESSION_IDS.continuable), renderLog( + header(VFS_EXAMPLE_SESSION_IDS.continuable, CREATED_AT + 200_000, { + parentSession: VFS_EXAMPLE_SESSION_IDS.main, + mode: 'continuable', + }), + continuableLog(), + )], + ]) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c25bbf2ce..e763188fd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4848,6 +4848,15 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local diff --git a/scripts/session-fixture-layout.spec.ts b/scripts/session-fixture-layout.spec.ts index c0b87953b7..05dfd7a4d0 100644 --- a/scripts/session-fixture-layout.spec.ts +++ b/scripts/session-fixture-layout.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { type SessionEvent } from '@deepseek-ai/dsh-session' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { canonicalSessionFixture } from './session-fixture-layout.ts' +import { canonicalSessionFixture, isPhysicalSessionFixture } from './session-fixture-layout.ts' const HEADER = ' {"type":"session","version":0,"id":"fixture","createdAt":1,"delegationDepth":0} ' @@ -68,3 +68,15 @@ describe('canonicalSessionFixture', () => { .toThrow(/broken\.jsonl: session snapshot line 2: malformed text-chunks storage row/) }) }) + +describe('isPhysicalSessionFixture', () => { + it('excludes only persisted logs under the WebWorker example root', () => { + expect(isPhysicalSessionFixture( + 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/main/session.jsonl', + )).toBe(true) + expect(isPhysicalSessionFixture( + 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/README.jsonl', + )).toBe(false) + expect(isPhysicalSessionFixture('apps/web/tests/snapshots/example/session.jsonl')).toBe(false) + }) +}) diff --git a/scripts/session-fixture-layout.ts b/scripts/session-fixture-layout.ts index 996b40fbca..e26cb7f90b 100644 --- a/scripts/session-fixture-layout.ts +++ b/scripts/session-fixture-layout.ts @@ -7,6 +7,10 @@ import { resolve } from 'node:path' import { packChunkRuns, type SessionEvent } from '@deepseek-ai/dsh-session' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +/** Physical persistence artifacts validated by the WebWorker runtime fixture spec. */ +const PHYSICAL_SESSION_FIXTURE_ROOT = + 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/' + /** One repository session fixture and its canonical projected representation. */ export interface SessionFixtureLayout { /** Repository-relative path with `/` separators. */ @@ -17,6 +21,16 @@ export interface SessionFixtureLayout { canonical: string } +/** + * Whether a repository JSONL is a production-layout persistence artifact rather + * than an envelope-free replay snapshot owned by this script. + * @param path - Repository-relative path with `/` separators. + * @returns True only for Session logs under the WebWorker VFS example root. + */ +export function isPhysicalSessionFixture(path: string): boolean { + return path.startsWith(PHYSICAL_SESSION_FIXTURE_ROOT) && path.endsWith('/session.jsonl') +} + function isSessionHeader(value: unknown): boolean { return value !== null && typeof value === 'object' && (value as { type?: unknown }).type === 'session' } @@ -109,6 +123,7 @@ function discoverJsonlFiles(root: string): string[] { */ export function inspectSessionFixtureLayouts(root: string): SessionFixtureLayout[] { return discoverJsonlFiles(root).flatMap((path) => { + if (isPhysicalSessionFixture(path)) return [] const source = readFileSync(resolve(root, path), 'utf8') const canonical = canonicalSessionFixture(source, path) return canonical === undefined ? [] : [{ path, source, canonical }] From 14bd300880e83960758e67023ac9be0bcf8226e4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:26:03 +0800 Subject: [PATCH 020/138] fix(webworker): match preview chooser styling --- .../src/client/source-chooser.ts | 174 +++++++++++++----- 1 file changed, 130 insertions(+), 44 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/client/source-chooser.ts b/packages/experimental/webworker-runtime/src/client/source-chooser.ts index 6a5174ccd3..50f4e3769f 100644 --- a/packages/experimental/webworker-runtime/src/client/source-chooser.ts +++ b/packages/experimental/webworker-runtime/src/client/source-chooser.ts @@ -17,64 +17,148 @@ interface PreviewSourceChoice { } const CHOOSER_STYLE = ` - :root { color-scheme: light dark; } - body { margin: 0; } [data-preview-source-chooser] { - min-height: 100vh; + position: fixed; + inset: 0; + z-index: 1200; display: grid; place-items: center; + overflow: auto; padding: 24px; box-sizing: border-box; - color: #171717; - background: radial-gradient(circle at 50% 35%, #eef4ff 0, #f8fafc 42%, #f3f4f6 100%); - font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #0f1115; + background: #fff; + font-size: 14px; + line-height: 22px; } + #root:has(> [data-preview-source-chooser]) > [data-dsh-boot] { display: none; } [data-preview-source-card] { - width: min(560px, 100%); + width: min(600px, 100%); + max-height: calc(100dvh - 48px); box-sizing: border-box; padding: 28px; - border: 1px solid #d8dee9; - border-radius: 20px; - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12); + overflow-y: auto; + border: 1px solid transparent; + border-radius: 24px; + background: #fff; + box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 12px 32px rgb(0 0 0 / 8%); } - [data-preview-source-card] h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.25; } - [data-preview-source-card] > p { margin: 0 0 22px; color: #5b6472; } - [data-preview-source-card] fieldset { display: grid; gap: 10px; margin: 0; padding: 0; border: 0; } - [data-preview-source-card] legend { margin-bottom: 10px; font-weight: 650; } - [data-preview-source-option] { - display: grid; - grid-template-columns: auto 1fr; - gap: 2px 12px; - padding: 14px; - border: 1px solid #d8dee9; - border-radius: 12px; - cursor: pointer; + [data-preview-source-card] h1 { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 500; } - [data-preview-source-option]:has(input:checked) { border-color: #4777df; background: #edf3ff; } - [data-preview-source-option]:has(input:disabled) { cursor: not-allowed; opacity: 0.55; } - [data-preview-source-option] input { grid-row: 1 / span 2; margin: 4px 0 0; } - [data-preview-source-option] strong { font-size: 15px; } - [data-preview-source-option] span { color: #667085; } - [data-preview-source-submit] { - width: 100%; - margin-top: 20px; - padding: 11px 16px; + [data-preview-source-card] > p { + margin: 8px 0 0; + color: #61666b; + } + [data-preview-source-card] fieldset { + display: flex; + flex-direction: column; + gap: 1px; + margin: 24px 0 0; + padding: 0; border: 0; - border-radius: 10px; - color: white; - background: #315fc7; - font: inherit; - font-weight: 650; + } + [data-preview-source-card] legend { + margin: 0 0 8px; + padding: 0 4px; + color: #61666b; + font-size: 13px; + line-height: 20px; + font-weight: 500; + } + [data-preview-source-option] { + position: relative; + display: flex; + align-items: flex-start; + gap: 8px; + min-height: 56px; + padding: 8px 12px 8px 8px; + box-sizing: border-box; + border: 1px solid transparent; + border-radius: 12px; + background: transparent; cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; + } + [data-preview-source-option]:hover:not(:has(input:disabled)), + [data-preview-source-option]:has(input:checked) { + background: rgb(38 49 72 / 6%); + } + [data-preview-source-option]:has(input:checked) { + border-color: rgb(0 0 0 / 10%); + } + [data-preview-source-option]:has(input:disabled) { + cursor: default; + opacity: 0.4; + } + [data-preview-source-option] input { + flex: none; + width: 16px; + height: 16px; + margin: 4px 0 0; + accent-color: #0f1115; + } + [data-preview-source-option] > span { flex: 1; min-width: 0; } + [data-preview-source-option] strong { + display: block; + font-size: 14px; + line-height: 24px; + font-weight: 500; + } + [data-preview-source-option] strong + span { + display: block; + color: #81858c; + font-size: 14px; + line-height: 24px; + } + [data-preview-source-submit] { + display: block; + min-width: 120px; + height: 36px; + margin: 24px 0 0 auto; + padding: 0 14px; + border: 0; + border-radius: 18px; + color: #fff; + background: #0f1115; + font-size: 14px; + line-height: 22px; + cursor: pointer; + transition: background-color 120ms ease; + } + [data-preview-source-submit]:hover:not(:disabled) { + background: #43454a; + } + [data-preview-source-submit]:focus-visible { + outline: 2px solid rgb(0 0 0 / 16%); + outline-offset: 2px; } [data-preview-source-submit]:disabled { cursor: not-allowed; opacity: 0.5; } @media (prefers-color-scheme: dark) { - [data-preview-source-chooser] { color: #f4f4f5; background: radial-gradient(circle at 50% 35%, #172554 0, #111827 45%, #09090b 100%); } - [data-preview-source-card] { border-color: #374151; background: rgba(24, 24, 27, 0.96); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); } - [data-preview-source-card] > p, [data-preview-source-option] span { color: #a1a1aa; } - [data-preview-source-option] { border-color: #3f3f46; } - [data-preview-source-option]:has(input:checked) { border-color: #7aa2ff; background: #172554; } + [data-preview-source-chooser] { + color: #f9fafb; + background: #151517; + } + [data-preview-source-card] { border-color: rgb(255 255 255 / 6%); background: #2c2c2e; } + [data-preview-source-card] > p, [data-preview-source-card] legend { color: #cfd3d6; } + [data-preview-source-option] strong + span { color: #adb2b8; } + [data-preview-source-option]:hover:not(:has(input:disabled)), + [data-preview-source-option]:has(input:checked) { background: rgb(255 255 255 / 8%); } + [data-preview-source-option]:has(input:checked) { border-color: rgb(255 255 255 / 12%); } + [data-preview-source-option] input { accent-color: #f9fafb; } + [data-preview-source-submit] { color: #0f1115; background: #f9fafb; } + [data-preview-source-submit]:hover:not(:disabled) { background: #ebeef2; } + [data-preview-source-submit]:focus-visible { outline-color: rgb(255 255 255 / 20%); } + } + @media (max-width: 560px) { + [data-preview-source-card] { padding: 24px; } + [data-preview-source-submit] { width: 100%; } + } + @media (prefers-reduced-motion: reduce) { + [data-preview-source-option], [data-preview-source-submit] { transition: none; } } ` @@ -89,8 +173,10 @@ function escapeMarkup(value: string): string { function optionMarkup(choice: PreviewSourceChoice, selected: string): string { return `` } From 4f80422595cdcf787a089c712fd312414b89c1a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:56:41 +0800 Subject: [PATCH 021/138] fix(webworker): preserve preview loading sequence --- apps/web/tests/preview-boot.e2e.ts | 9 +++--- .../preview-boot/source-chooser.expected.md | 30 +++++++++---------- .../webworker-packer/src/repository.ts | 4 +-- .../src/client/source-chooser.ts | 30 +++++++++---------- .../tests/client/source-chooser.spec.ts | 12 ++++++-- 5 files changed, 47 insertions(+), 38 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index b4675926bd..8ef63e1067 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -283,10 +283,10 @@ async function bootPreview(origin: string, browser: Browser): Promise { }) try { await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' }) - await page.getByRole('heading', { name: '选择 Preview 数据源' }).waitFor() + await page.getByRole('heading', { name: 'Choose Preview data' }).waitFor() expect(await page.locator('input[name="preview-source"][value="vfs-example"]').isChecked()).toBe(true) - expect(await page.getByText('空白环境', { exact: true }).count()).toBe(1) - expect(await page.getByText('WebFS 目录', { exact: true }).count()).toBe(1) + expect(await page.getByText('Empty environment', { exact: true }).count()).toBe(1) + expect(await page.getByText('WebFS directory', { exact: true }).count()).toBe(1) expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true) expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0) await compareOrRefreshGolden( @@ -294,7 +294,8 @@ async function bootPreview(origin: string, browser: Browser): Promise { await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'), SNAPSHOT_MODE, ) - await page.getByRole('button', { name: '启动 Preview' }).click() + await page.getByRole('button', { name: 'Start Preview' }).click() + await page.getByText('Loading plugins…', { exact: true }).waitFor({ timeout: 10_000 }) const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`) // The activated tree ran bodies lowered against the contract this // checkout's packer emits; a dist built before a contract change would diff --git a/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md b/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md index 18dd2a442b..1ba068c6b5 100644 --- a/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md +++ b/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md @@ -1,15 +1,15 @@ -- form "选择 Preview 数据源": - - heading "选择 Preview 数据源" [level=1] - - paragraph: 数据会在 Worker 和应用启动前挂载;刷新页面可重新选择。 - - group "文件系统来源": - - text: 文件系统来源 - - radio "空白环境 只加载基础运行时,用于验证首次启动与新建 Workspace。" - - strong: 空白环境 - - text: 只加载基础运行时,用于验证首次启动与新建 Workspace。 - - radio "内置综合示例 示例 Workspace、工具卡、子代理与分页会话。" [checked] - - strong: 内置综合示例 - - text: 示例 Workspace、工具卡、子代理与分页会话。 - - radio "WebFS 目录 需要用户授权的目录来源,将在 WebFS provider 接入后开放。" [disabled] - - strong: WebFS 目录 - - text: 需要用户授权的目录来源,将在 WebFS provider 接入后开放。 - - button "启动 Preview" +- form "Choose Preview data": + - heading "Choose Preview data" [level=1] + - paragraph: Data mounts before the Worker and application start. Refresh to choose again. + - group "Filesystem source": + - text: Filesystem source + - radio "Empty environment Load only the base runtime to verify first launch and workspace creation." + - strong: Empty environment + - text: Load only the base runtime to verify first launch and workspace creation. + - radio "Built-in showcase Sample workspace, tool cards, subagents, and paged history." [checked] + - strong: Built-in showcase + - text: Sample workspace, tool cards, subagents, and paged history. + - radio "WebFS directory Requires directory access and will be available after the WebFS provider lands." [disabled] + - strong: WebFS directory + - text: Requires directory access and will be available after the WebFS provider lands. + - button "Start Preview" diff --git a/packages/experimental/webworker-packer/src/repository.ts b/packages/experimental/webworker-packer/src/repository.ts index 5e433ea425..cdfda6e547 100644 --- a/packages/experimental/webworker-packer/src/repository.ts +++ b/packages/experimental/webworker-packer/src/repository.ts @@ -156,8 +156,8 @@ export function previewFixtures(repoRoot: string): PreviewFixture[] { const root = join(repoRoot, PREVIEW_EXAMPLE_ROOT) return [{ id: 'vfs-example', - label: '内置综合示例', - description: '示例 Workspace、工具卡、子代理与分页会话。', + label: 'Built-in showcase', + description: 'Sample workspace, tool cards, subagents, and paged history.', trees: ['home', 'workspace'].map(mount => ({ mount, directory: join(root, mount) })), }] } diff --git a/packages/experimental/webworker-runtime/src/client/source-chooser.ts b/packages/experimental/webworker-runtime/src/client/source-chooser.ts index 50f4e3769f..48d454eb94 100644 --- a/packages/experimental/webworker-runtime/src/client/source-chooser.ts +++ b/packages/experimental/webworker-runtime/src/client/source-chooser.ts @@ -31,7 +31,6 @@ const CHOOSER_STYLE = ` font-size: 14px; line-height: 22px; } - #root:has(> [data-preview-source-chooser]) > [data-dsh-boot] { display: none; } [data-preview-source-card] { width: min(600px, 100%); max-height: calc(100dvh - 48px); @@ -206,15 +205,15 @@ export async function choosePreviewSource(manifestUrl: URL): Promise -
-

选择 Preview 数据源

-

数据会在 Worker 和应用启动前挂载;刷新页面可重新选择。

+ const chooser = document.createElement('main') + chooser.dataset.previewSourceChooser = '' + chooser.innerHTML = ` +

Choose Preview data

+

Data mounts before the Worker and application start. Refresh to choose again.

- 文件系统来源 + Filesystem source ${choices.map(choice => optionMarkup(choice, selected)).join('')}
- -
- ` - const form = root.querySelector('[data-preview-source-card]') + + ` + root.prepend(chooser) + const form = chooser.querySelector('[data-preview-source-card]') if (form === null) throw new Error('preview source chooser: form was not rendered') const sourceId = await new Promise((resolve, reject) => { form.addEventListener('submit', (event) => { @@ -258,7 +258,7 @@ export async function choosePreviewSource(manifestUrl: URL): Promise candidate.id === sourceId && candidate.disabled !== true) if (choice === undefined) throw new Error(`preview source chooser: unavailable source "${sourceId}"`) - root.replaceChildren() + chooser.remove() style.remove() return choice.overlays } diff --git a/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts b/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts index 6c629a2af8..f636c32ebe 100644 --- a/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts +++ b/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts @@ -80,6 +80,11 @@ describe('Preview source chooser', () => { it('shows the chooser only when the query is absent and returns its default selection', async () => { installManifest() + const root = document.getElementById('root') + if (root === null) throw new Error('test root is missing') + const bootPage = document.createElement('div') + bootPage.dataset.dshBoot = '' + root.append(bootPage) const selected = choosePreviewSource(MANIFEST_URL) await vi.waitFor(() => { @@ -97,7 +102,9 @@ describe('Preview source chooser', () => { new URL('https://preview.test/preview/fixtures/base.tar.gz'), new URL('https://preview.test/preview/fixtures/tail.tar.gz'), ]) - expect(document.getElementById('root')?.childElementCount).toBe(0) + expect(root.contains(bootPage)).toBe(true) + expect(root.childElementCount).toBe(1) + expect(document.querySelector('[data-preview-source-chooser]')).toBeNull() expect(document.querySelector('[data-preview-source-style]')).toBeNull() }) @@ -124,8 +131,9 @@ describe('Preview source chooser', () => { document.body.innerHTML = '
' const root = document.getElementById('root') if (root === null) throw new Error('test root is missing') - vi.spyOn(root, 'querySelector').mockReturnValueOnce(null) + const querySelector = vi.spyOn(HTMLElement.prototype, 'querySelector').mockReturnValueOnce(null) await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/form was not rendered/) + querySelector.mockRestore() document.head.replaceChildren() document.body.innerHTML = '
' From 5ad9b128f9b6ec0e120b299591f2bcfbd3f43159 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:39:48 +0800 Subject: [PATCH 022/138] fix(webworker): align filesystem semantics with Node --- ...webworker-vfs-watch-and-landlock.i18n.yaml | 4 +- ...-08-23-webworker-vfs-watch-and-landlock.md | 6 +- ...-23-webworker-vfs-watch-and-landlock.zh.md | 6 +- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../webworker-runtime/src/fixture-manifest.ts | 1 + .../implemented/abort-error.ts | 14 + .../builtin_modules/implemented/fs-watch.ts | 18 +- .../node/builtin_modules/implemented/fs.ts | 112 ++++---- .../src/shell/process/landlock.ts | 3 +- .../webworker-runtime/src/storage/memory.ts | 263 ++++++++++++++---- .../webworker-runtime/src/storage/types.ts | 46 ++- .../tests/node/child-process.spec.ts | 8 + .../tests/node/fs-watch-stream.spec.ts | 229 ++++++++++++++- .../tests/storage/memory-vfs.spec.ts | 56 +++- 16 files changed, 614 insertions(+), 160 deletions(-) create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/abort-error.ts diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml index 34a24f6261..4260ae5238 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.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-23-webworker-vfs-watch-and-landlock.md -2026-08-23-webworker-vfs-watch-and-landlock.md: 61254092e0f32e8e4291fd7c21489684110a1d15 -2026-08-23-webworker-vfs-watch-and-landlock.zh.md: 32e1b36e0ef17e4574252693e246f9d7cd4d4712 +2026-08-23-webworker-vfs-watch-and-landlock.md: 2705c63aa6bf0f2e2de33d00029a4f41e1b1d4af +2026-08-23-webworker-vfs-watch-and-landlock.zh.md: 4470720e1ff968aa06578b231b054c9053f27283 diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md index 61254092e0..2705c63aa6 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md @@ -20,13 +20,13 @@ The filesystem compatibility boundary follows the [Worker Node face decision](20 The mutation record is shared with WebFS persistence rather than defining a second notification path. Writes carry their complete post-commit bytes and virtual permission bits, plus an append offset when only a tail changed. `MemoryVfs` accepts an optional asynchronous `VfsMutationSink`, sends the same records to that sink and live watcher subscribers, and exposes `flush()` through file-handle `sync()` and `datasync()`. Hydration supplies `{ mode, mtimeMs }` explicitly, so image permissions and durable timestamps cannot occupy the same positional argument. This change mounts no durable sink; it keeps the synchronous in-memory tree authoritative so an OPFS or user-directory mirror can hydrate before publication and write behind without changing `node:fs`. -The `node:fs` implementation provides callback `stat` and `lstat`, `watch`, `watchFile`, `unwatchFile`, `FSWatcher`, and `StatWatcher`; `node:fs/promises.watch` provides the abortable async iterator. One path shares one `StatWatcher` across listeners, listener-specific unwatching leaves peers active, and missing paths report zero-valued Stats before later creation, deletion, and recreation transitions. Callback dispatch captures the registration-time async context and checks closure before every queued delivery. +The `node:fs` implementation provides callback `stat` and `lstat`, `watch`, `watchFile`, `unwatchFile`, `FSWatcher`, and `StatWatcher`; `node:fs/promises.watch` provides the abortable async iterator. One path shares one `StatWatcher` across listeners, listener-specific unwatching leaves peers active, and missing paths report zero-valued Stats before later creation, deletion, and recreation transitions. Callback dispatch captures the registration-time async context and checks closure before every queued delivery. A pre-aborted callback watch returns its watcher before asynchronously closing it, while a pre-aborted promise watch rejects its first iterator read with `AbortError`. `fs.watch` maps entry creation, removal, and rename destinations to `rename`, and maps content or mode changes to `change`. Non-recursive directory watches report immediate child names; recursive watches report paths relative to the watched directory. The VFS has no symlinks, so this implementation does not invent symlink events. ### Streams and unchanged npm packages -`node:stream` uses the maintained `readable-stream` browser implementation for `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, pipeline helpers, async iteration, backpressure, aborts, and teardown ordering. The compatibility module sets the byte high-water default to the 64 KiB value used by the repository's Node 22+ engines. VFS-backed `ReadStream` and `WriteStream` supply file descriptors, inclusive ranges, encoding, append or replace behavior, byte accounting, AbortSignal handling, and `open`/`ready`/`finish`/`end`/`close` ordering. +`node:stream` uses the maintained `readable-stream` browser implementation for `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, pipeline helpers, async iteration, backpressure, aborts, and teardown ordering. The compatibility module sets the byte high-water default to the 64 KiB value used by the repository's Node 22+ engines. VFS-backed `ReadStream` and `WriteStream` supply file descriptors, inclusive ranges, encoding, append or replace behavior, byte accounting, AbortSignal handling, and `open`/`ready`/`finish`/`end`/`close` ordering. Descriptors retain their opened file identity and access mode across rename, replacement, and unlink; hard links share that identity and subsequent content or mode changes, while truncation zero-fills growth. Chokidar and readdirp are ordinary image dependencies, not module replacements. Their package code runs unchanged and imports the Worker implementations of `node:fs`, `node:fs/promises`, `node:stream`, `node:events`, `node:path`, and `node:os`. Chokidar therefore retains its own initial scan, `ready`, polling, atomic-write normalization, write-settle delay, shared watcher, and close behavior. @@ -36,7 +36,7 @@ Chokidar and readdirp are ordinary image dependencies, not module replacements. The process layer has a table of Worker platform executables identified by logical executable name rather than one package-manager path. Its `landlock-run` provider accepts a bare command or an absolute launcher path, parses the native package's unchanged CLI, validates every grant root, and delegates the inner argv to the existing shell process runner. `node:child_process` performs only generic executable lookup, output delivery, and settlement. The unchanged package's synchronous `probe()` therefore observes the provider through `spawnSync` and reports `full`. A usage error, missing grant root, or unknown inner executable prints one `landlock-run: ...` line, exits `125`, and never runs the inner command. The bwrap probe remains unavailable, so the unmodified `sandbox-local` Linux chain selects this Landlock backend. -Each launched process receives its own `ShellFileSystem` guard. `stat`, `list`, and `readText` require a read-only or read-write grant; `writeText`, `mkdir`, and `remove` require a read-write grant; `rename` requires both source and destination to be writable. Denials carry `EACCES` and `permission denied`, preserving `bash-sandbox` denial classification. `/tmp` maps to the VFS `/dsh/tmp`, while `/dev/null` is a virtual empty-read and discarded-write file that stores no bytes. +Each launched process receives its own `ShellFileSystem` guard. `stat`, `list`, and `readText` require a read-only or read-write grant; `writeText`, `mkdir`, and `remove` require a read-write grant; `rename` requires both source and destination to be writable. Grant roots normalize trailing separators before containment checks. Denials carry `EACCES` and `permission denied`, preserving `bash-sandbox` denial classification. `/tmp` maps to the VFS `/dsh/tmp`, while `/dev/null` is a virtual empty-read and discarded-write file that stores no bytes. The Worker's `full` verdict covers every file operation expressible through its shell command table and Host-served VFS protocol. It does not claim Linux kernel Landlock, arbitrary native executable support, or protection against a future shell program that bypasses `ShellFileSystem`. diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md index 32e1b36e0e..4470720e1f 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md @@ -20,13 +20,13 @@ Web Worker preview 启动与 Node host 相同的 Web profile 和 Agent preset。 Mutation record 与 WebFS 持久化共用,而不建立第二条通知路径。Write 记录携带提交后的完整字节与虚拟权限位,并在只有尾部变化时携带 append offset。`MemoryVfs` 接受可选的异步 `VfsMutationSink`,把同一批记录交给 sink 与实时 watcher 订阅方,并通过文件句柄的 `sync()` 和 `datasync()` 暴露 `flush()`。水合通过显式的 `{ mode, mtimeMs }` 传入元数据,因此镜像权限与持久化时间戳不会占用同一个位置参数。本次变更不挂载 durable sink;同步内存树继续作为权威,因此 OPFS 或用户目录 mirror 可以先水合、再异步写回,而无需改变 `node:fs`。 -`node:fs` 实现 callback `stat` 和 `lstat`、`watch`、`watchFile`、`unwatchFile`、`FSWatcher` 与 `StatWatcher`;`node:fs/promises.watch` 提供可由 abort 取消的异步迭代器。同一路径的 listener 共享一个 `StatWatcher`,按 listener 取消监听不会影响其他 listener;缺失路径先报告零值 Stats,随后再报告创建、删除和重建状态。Callback 分发捕获注册时的异步上下文,并在每次排队交付前检查 watcher 是否已经关闭。 +`node:fs` 实现 callback `stat` 和 `lstat`、`watch`、`watchFile`、`unwatchFile`、`FSWatcher` 与 `StatWatcher`;`node:fs/promises.watch` 提供可由 abort 取消的异步迭代器。同一路径的 listener 共享一个 `StatWatcher`,按 listener 取消监听不会影响其他 listener;缺失路径先报告零值 Stats,随后再报告创建、删除和重建状态。Callback 分发捕获注册时的异步上下文,并在每次排队交付前检查 watcher 是否已经关闭。预先 abort 的 callback watcher 先返回对象、再异步关闭;预先 abort 的 promise watcher 在第一次读取 iterator 时以 `AbortError` 拒绝。 `fs.watch` 把条目创建、删除和 rename 目标映射为 `rename`,把内容或 mode 变化映射为 `change`。非递归目录 watcher 报告直接子项名,递归 watcher 报告相对被监听目录的路径。VFS 没有符号链接,因此该实现不会制造符号链接事件。 ### Stream 与未修改的 NPM 包 -`node:stream` 使用维护中的 `readable-stream` 浏览器实现来提供 `Readable`、`Writable`、`Duplex`、`Transform`、`PassThrough`、pipeline helper、异步迭代、backpressure、abort 和 teardown 顺序。兼容模块把字节流 high-water mark 默认值设为仓库 Node 22+ 引擎使用的 64 KiB。VFS 支持的 `ReadStream` 与 `WriteStream` 提供文件描述符、闭区间范围、encoding、追加或替换行为、字节计数、AbortSignal 处理,以及 `open`、`ready`、`finish`、`end`、`close` 顺序。 +`node:stream` 使用维护中的 `readable-stream` 浏览器实现来提供 `Readable`、`Writable`、`Duplex`、`Transform`、`PassThrough`、pipeline helper、异步迭代、backpressure、abort 和 teardown 顺序。兼容模块把字节流 high-water mark 默认值设为仓库 Node 22+ 引擎使用的 64 KiB。VFS 支持的 `ReadStream` 与 `WriteStream` 提供文件描述符、闭区间范围、encoding、追加或替换行为、字节计数、AbortSignal 处理,以及 `open`、`ready`、`finish`、`end`、`close` 顺序。Descriptor 在 rename、replacement 和 unlink 后仍保留打开时的文件身份与访问模式;hard link 共享该身份及后续内容和 mode 变化,truncate 增长则用零字节填充。 Chokidar 和 readdirp 作为普通镜像依赖运行,不属于模块 replacement。它们的包代码保持原样,并导入 Worker 实现的 `node:fs`、`node:fs/promises`、`node:stream`、`node:events`、`node:path` 与 `node:os`。因此,初次扫描、`ready`、polling、原子写归一化、写入稳定等待、共享 watcher 与关闭行为仍由 Chokidar 自己负责。 @@ -36,7 +36,7 @@ Chokidar 和 readdirp 作为普通镜像依赖运行,不属于模块 replaceme 进程层持有按逻辑可执行文件名识别的 Worker 平台可执行文件表,而不依赖某一个包管理器路径。其 `landlock-run` provider 接受裸命令或绝对 launcher 路径,解析 native 包未经修改的 CLI、校验每个授权根,并把内部 argv 交给既有 shell 进程 runner。`node:child_process` 只负责通用的可执行文件查找、输出投递与结束处理。因此,原包的同步 `probe()` 会通过 `spawnSync` 观察到该 provider 并报告 `full`。用法错误、缺失的授权根或未知内部可执行文件只输出一行 `landlock-run: ...`,以 `125` 退出,并且绝不运行内部命令。bwrap 仍探测为不可用,因此未修改的 `sandbox-local` Linux 选择链会选中该 Landlock 后端。 -每个已启动进程分别获得一个 `ShellFileSystem` guard。`stat`、`list` 和 `readText` 需要只读或读写授权;`writeText`、`mkdir` 和 `remove` 需要读写授权;`rename` 要求源和目标都可写。拒绝错误包含 `EACCES` 与 `permission denied`,从而保持 `bash-sandbox` 的拒绝分类。`/tmp` 映射到 VFS 的 `/dsh/tmp`,`/dev/null` 则是空读、丢弃写入且不保存任何字节的虚拟文件。 +每个已启动进程分别获得一个 `ShellFileSystem` guard。`stat`、`list` 和 `readText` 需要只读或读写授权;`writeText`、`mkdir` 和 `remove` 需要读写授权;`rename` 要求源和目标都可写。Grant root 在 containment 检查前去除尾部分隔符。拒绝错误包含 `EACCES` 与 `permission denied`,从而保持 `bash-sandbox` 的拒绝分类。`/tmp` 映射到 VFS 的 `/dsh/tmp`,`/dev/null` 则是空读、丢弃写入且不保存任何字节的虚拟文件。 Worker 的 `full` 结论覆盖 shell 命令表和 Host 服务 VFS 协议能够表达的全部文件操作。它不表示 Linux 内核 Landlock、不支持任意 native 可执行文件,也无法约束未来绕过 `ShellFileSystem` 的 shell 程序。 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index f5d10e0a50..9b012cd360 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: 5b63856b826d4f8bc8b6ff56626c6e7a1ed663b1 -README.zh.md: 59b31bd308077b8eeaa60edf5bd23a75924eaa07 +README.md: df6dacad35273f8c636a86c7c260b4f5d1958a6a +README.zh.md: 721770149c04169d813d2b5d3d4faafdccf1dec5 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 5b63856b82..df6dacad35 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -7,7 +7,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 59b31bd308..721770149c 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -7,7 +7,7 @@ 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 diff --git a/packages/experimental/webworker-runtime/src/fixture-manifest.ts b/packages/experimental/webworker-runtime/src/fixture-manifest.ts index d594960576..7d2ecf2556 100644 --- a/packages/experimental/webworker-runtime/src/fixture-manifest.ts +++ b/packages/experimental/webworker-runtime/src/fixture-manifest.ts @@ -17,6 +17,7 @@ export interface PreviewFixtureManifestEntry { /** Complete built-in fixture catalog consumed before Worker startup. */ export interface PreviewFixtureManifest { readonly version: number + /** Required default fixture id, or null when the chooser should default to an empty overlay. */ readonly defaultFixture: string | null readonly fixtures: readonly PreviewFixtureManifestEntry[] } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/abort-error.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/abort-error.ts new file mode 100644 index 0000000000..a7f188bdc1 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/abort-error.ts @@ -0,0 +1,14 @@ +/** Build the Node-style cancellation error shared by abortable builtin APIs. */ + +/** + * Create an `AbortError` carrying Node's stable error code. + * @param reason - Optional AbortSignal reason exposed as the error cause. + * @returns A Node-compatible abort error. + */ +export function abortError(reason?: unknown): Error & { code: string; cause?: unknown } { + const error = new Error('The operation was aborted') as Error & { code: string; cause?: unknown } + error.name = 'AbortError' + error.code = 'ABORT_ERR' + if (reason !== undefined) error.cause = reason + return error +} diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts index 71864a7caf..61e3f4419c 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts @@ -5,6 +5,7 @@ import { captureAsyncContext, runWithAsyncContext } from './async_hooks.ts' import { basename, relative, resolve, sep } from './path.ts' import { requireActiveVfs } from '../../../storage/active.ts' import type { VfsBigIntStats, VfsMutation, VfsStats } from '../../../storage/types.ts' +import { abortError } from './abort-error.ts' type PathArg = string | URL | Uint8Array type WatchListener = (eventType: 'rename' | 'change', filename: string | Buffer | null) => void @@ -82,14 +83,6 @@ const contains = (parent: string, child: string): boolean => const overlaps = (left: string, right: string): boolean => contains(left, right) || contains(right, left) -const abortError = (reason?: unknown): Error & { code: string; cause?: unknown } => { - const error = new Error('The operation was aborted') as Error & { code: string } - error.name = 'AbortError' - error.code = 'ABORT_ERR' - if (reason !== undefined) error.cause = reason - return error -} - /** `fs.FSWatcher` over VFS mutations. */ export class FSWatcher extends EventEmitter { private readonly disposeMutation: () => void @@ -122,9 +115,8 @@ export class FSWatcher extends EventEmitter { this.signal = options.signal this.onAbort = options.signal === undefined ? undefined : () => { this.close() } if (options.signal?.aborted === true) { - this.disposeMutation() - this.closed = true - throw abortError(options.signal.reason) + this.close() + return } options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) } @@ -372,6 +364,10 @@ export function watchAsync( const onAbort = (): void => { settleFailure(abortError(options.signal?.reason)) } const start = (): void => { if (watcher !== undefined || closed || failure !== undefined) return + if (options.signal?.aborted === true) { + settleFailure(abortError(options.signal.reason)) + return + } try { watcher = watch(path, options, (eventType, filename) => { const event = { eventType, filename } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index 0aea178db4..4e83d52752 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -5,10 +5,13 @@ * file descriptors, `mkdtemp`, access checks, watchers, streams, and the promise face. */ import { requireActiveVfs } from '../../../storage/active.ts' -import type { Vfs, VfsBigIntStats, VfsStatOptions, VfsStats, VfsWriteOptions } from '../../../storage/types.ts' +import type { + Vfs, VfsBigIntStats, VfsOpenFile, VfsStatOptions, VfsStats, VfsWriteOptions, +} from '../../../storage/types.ts' import { Buffer } from 'buffer' import { Readable, Writable } from './stream.ts' import { dirname } from './path.ts' +import { abortError } from './abort-error.ts' import { FSWatcher, StatWatcher, unwatchFile, watch, watchAsync, watchFile, } from './fs-watch.ts' @@ -166,11 +169,14 @@ export function stat( const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback if (callback === undefined) throw new TypeError('The "callback" argument must be of type function') queueMicrotask(() => { + let result: VfsStats | VfsBigIntStats try { - callback(null, statSync(path, options)) + result = statSync(path, options) } catch (error) { callback(error as NodeJS.ErrnoException) + return } + callback(null, result) }) } @@ -290,9 +296,8 @@ export function accessSync(path: PathArg): void { } interface OpenFile { - path: string + file: VfsOpenFile position: number - append: boolean } const openFiles = new Map() @@ -308,25 +313,20 @@ let nextFd = 3 */ export function openSync(path: PathArg, flags = 'r', mode?: number): number { const target = asPath(path) - const exists = vfs().existsSync(target) - if (flags.includes('x') && exists) { - const error = new Error(`EEXIST: file already exists, open '${target}'`) as Error & { code: string; path: string } - error.code = 'EEXIST' - error.path = target - throw error - } - if (flags.startsWith('r')) vfs().realpathSync(target) - else if (flags.startsWith('w') || !exists) { - vfs().writeFileSync(target, new Uint8Array(0), mode === undefined ? undefined : { mode }) - } + const file = vfs().openFileSync(target, flags, mode) const fd = nextFd++ - openFiles.set(fd, { path: target, position: 0, append: flags.startsWith('a') }) + openFiles.set(fd, { file, position: 0 }) return fd } const fileOf = (fd: number, syscall: string): OpenFile => { const file = openFiles.get(fd) - if (file === undefined) throw new Error(`EBADF: bad file descriptor, ${syscall}`) + if (file === undefined) { + const error = new Error(`EBADF: bad file descriptor, ${syscall}`) as Error & { code: string; syscall: string } + error.code = 'EBADF' + error.syscall = syscall + throw error + } return file } @@ -347,9 +347,8 @@ export function readSync( position: number | null = null, ): number { const file = fileOf(fd, 'read') - const bytes = bytesOf(file.path) const from = position ?? file.position - const slice = bytes.subarray(from, from + length) + const slice = file.file.read(from, length) buffer.set(slice, offset) if (position === null) file.position = from + slice.byteLength return slice.byteLength @@ -364,17 +363,10 @@ export function readSync( export function writeSync(fd: number, data: string | Uint8Array): number { const file = fileOf(fd, 'write') const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data - if (file.append) { - vfs().appendFileSync(file.path, bytes) - return bytes.byteLength - } - const existing = vfs().existsSync(file.path) ? bytesOf(file.path) : new Uint8Array(0) - const merged = new Uint8Array(Math.max(existing.byteLength, file.position + bytes.byteLength)) - merged.set(existing, 0) - merged.set(bytes, file.position) - vfs().writeFileSync(file.path, merged) - file.position += bytes.byteLength - return bytes.byteLength + const position = file.file.append ? file.file.stat().size : file.position + const bytesWritten = file.file.write(position, bytes) + file.position = position + bytesWritten + return bytesWritten } /** @@ -382,17 +374,16 @@ export function writeSync(fd: number, data: string | Uint8Array): number { * @param fd - descriptor. */ export function closeSync(fd: number): void { - openFiles.delete(fd) + if (!openFiles.delete(fd)) fileOf(fd, 'close') } /** - * Create a second name for one file's contents. Hard links do not exist in the - * VFS, so the bytes are copied. + * Create a second name for one file identity. * @param from - existing path. * @param to - new path. */ export function linkSync(from: PathArg, to: PathArg): void { - writeFileSync(to, bytesOf(asPath(from))) + vfs().linkSync(asPath(from), asPath(to)) } /** @@ -424,30 +415,40 @@ export interface FileHandle { export function openHandleSync(path: PathArg, flags = 'r', mode?: number): FileHandle { const target = asPath(path) const directory = vfs().existsSync(target) && vfs().statSync(target).isDirectory() - const append = flags.startsWith('a') const fd = directory ? -1 : openSync(target, flags, mode) + let closed = false + const descriptor = (syscall: string): OpenFile => fileOf(fd, syscall) return { fd, - readFile: async (options?: EncodingOption) => readFileSync(target, options), - // Node appends when the handle was opened with 'a'. The JSONL session log - // depends on it — `open(path, 'a')` then `writeFile(batch)` — and replacing - // the file there destroys the header frame its reader requires. + readFile: async (options?: EncodingOption) => { + if (directory) return readFileSync(target, options) + const open = descriptor('read') + const bytes = open.file.read(open.position, Math.max(0, open.file.stat().size - open.position)) + open.position += bytes.length + const encoding = encodingOf(options) + return encoding === undefined || encoding === 'utf8' || encoding === 'utf-8' + ? (encoding === undefined ? asBuffer(bytes) : new TextDecoder().decode(bytes)) + : asBuffer(bytes).toString(encoding) + }, writeFile: async (data: string | Uint8Array) => { - if (append) appendFileSync(target, data) - else writeFileSync(target, data) + if (directory) writeFileSync(target, data) + else writeSync(fd, data) }, write: async (data: string | Uint8Array) => ({ bytesWritten: writeSync(fd, data) }), read: async (buffer: Uint8Array, offset = 0, length = buffer.byteLength, position: number | null = null) => ({ bytesRead: readSync(fd, buffer, offset, length, position), buffer, }), - stat: async () => statSync(target) as VfsStats, + stat: async () => directory ? statSync(target) as VfsStats : descriptor('fstat').file.stat(), truncate: async (length = 0) => { - writeFileSync(target, bytesOf(target).subarray(0, length)) + if (directory) writeFileSync(target, new Uint8Array(length)) + else descriptor('ftruncate').file.truncate(length) }, sync: async () => { await vfs().flush() }, datasync: async () => { await vfs().flush() }, close: async () => { + if (closed) return + closed = true if (fd !== -1) closeSync(fd) }, } @@ -477,12 +478,8 @@ export interface WriteStreamOptions { signal?: AbortSignal } -const aborted = (reason?: unknown): Error => { - const error = new Error('The operation was aborted', { cause: reason }) as Error & { code: string } - error.name = 'AbortError' - error.code = 'ABORT_ERR' - return error -} +/** Node implements file-stream `autoClose` through the stream's `autoDestroy` state. */ +const streamAutoDestroy = (autoClose: boolean | undefined): boolean => autoClose ?? true /** Read stream over one VFS file. */ export class ReadStream extends Readable { @@ -503,7 +500,7 @@ export class ReadStream extends Readable { constructor(path: PathArg, options: ReadStreamOptions = {}) { super({ - autoDestroy: options.autoClose ?? true, + autoDestroy: streamAutoDestroy(options.autoClose), emitClose: options.emitClose ?? true, highWaterMark: options.highWaterMark ?? 64 * 1024, }) @@ -513,7 +510,7 @@ export class ReadStream extends Readable { this.flags = options.flags ?? 'r' this.position = this.start this.signal = options.signal - this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(aborted(options.signal?.reason)) } + this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(abortError(options.signal?.reason)) } if (options.encoding !== undefined && options.encoding !== null) this.setEncoding(options.encoding) options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) } @@ -524,7 +521,7 @@ export class ReadStream extends Readable { return } if (this.signal?.aborted === true) { - callback(aborted(this.signal.reason)) + callback(abortError(this.signal.reason)) return } try { @@ -598,7 +595,7 @@ export class WriteStream extends Writable { constructor(path: PathArg, options: WriteStreamOptions = {}) { super({ - autoDestroy: options.autoClose ?? true, + autoDestroy: streamAutoDestroy(options.autoClose), decodeStrings: true, defaultEncoding: options.encoding ?? 'utf8', emitClose: options.emitClose ?? true, @@ -609,7 +606,7 @@ export class WriteStream extends Writable { this.mode = options.mode this.start = options.start this.signal = options.signal - this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(aborted(options.signal?.reason)) } + this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(abortError(options.signal?.reason)) } options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) } @@ -619,7 +616,7 @@ export class WriteStream extends Writable { return } if (this.signal?.aborted === true) { - callback(aborted(this.signal.reason)) + callback(abortError(this.signal.reason)) return } try { @@ -772,13 +769,12 @@ export const promises = { mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, bytesOf(source)) }, - // The VFS has no inodes, so a hard link is a byte copy: the caller's contract - // is only that both names read the same content until one is removed. + // The VFS keeps both names attached to one file identity until either name is removed. link: async (from: PathArg, to: PathArg): Promise => { linkSync(from, to) }, open: async (path: PathArg, flags?: string, mode?: number): Promise => openHandleSync(path, flags, mode), opendir: async (path: PathArg): Promise
=> opendirSync(path), truncate: async (path: PathArg, length = 0): Promise => { - writeFileSync(path, bytesOf(asPath(path)).subarray(0, length)) + vfs().truncateSync(asPath(path), length) }, watch: watchAsync, constants, diff --git a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts index 319ee6b983..03ff455c64 100644 --- a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts +++ b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts @@ -51,7 +51,8 @@ export function parseLandlockArguments(args: readonly string[]): LandlockInvocat /** Map the host launcher's temp path into the Worker VFS. */ function vfsPath(path: string, cwd: string): string { - const absolute = resolve(cwd, path) + const resolved = resolve(cwd, path) + const absolute = resolved.length > 1 ? resolved.replace(/\/+$/u, '') : resolved if (absolute === '/tmp') return DSH_TMP if (absolute.startsWith('/tmp/')) return `${DSH_TMP}${absolute.slice('/tmp'.length)}` return absolute diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 6f41fbc9ee..7502e90bb8 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -8,7 +8,7 @@ import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-p import { IMAGE_OVERLAY_DIRECTORIES } from '../image-layout.ts' import { parseTar } from './tar.ts' import type { - Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, + Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, VfsOpenFile, VfsMutationListener, VfsMutationSink, VfsReadOptions, VfsSeedOptions, VfsStatOptions, VfsStats, VfsWriteOptions, } from './types.ts' @@ -20,6 +20,8 @@ interface FileNode { mtimeMs: number /** Permission bits (`0o777` mask), set at creation and changed only by `chmod`. */ mode: number + /** Stable identity shared by hard links and retained by open descriptors. */ + identity?: bigint } /** Creation default for files, Node's `0o666` under the classic `022` umask. */ @@ -80,7 +82,14 @@ function statsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, * @param mode - Stored permission bits of the entry. * @returns Stats in the shape Node returns under `{ bigint: true }`. */ -function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, mode: number): VfsBigIntStats { +function bigIntStatsOf( + size: number, + mtimeMs: number, + directory: boolean, + ino: bigint, + mode: number, + nlink = 1, +): VfsBigIntStats { const milliseconds = BigInt(Math.trunc(mtimeMs)) const nanoseconds = milliseconds * 1_000_000n const time = new Date(mtimeMs) @@ -89,7 +98,7 @@ function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: b mode: BigInt((directory ? 0o040000 : 0o100000) | (mode & 0o777)), dev: 1n, ino, - nlink: 1n, + nlink: BigInt(nlink), mtimeMs: milliseconds, mtimeNs: nanoseconds, ctimeMs: milliseconds, @@ -112,6 +121,49 @@ function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: b } } +interface OpenMode { + readonly readable: boolean + readonly writable: boolean + readonly append: boolean + readonly create: boolean + readonly truncate: boolean + readonly exclusive: boolean +} + +/** Parse the Node string flags supported by the compatibility filesystem. */ +function openMode(flags: string): OpenMode { + const base = flags[0] + const suffix = flags.slice(1).split('') + const validSuffix = suffix.every(flag => flag === '+' || flag === 'x' || flag === 's') + const uniqueSuffix = new Set(suffix).size === suffix.length + if ((base !== 'r' && base !== 'w' && base !== 'a') || !validSuffix || !uniqueSuffix + || base === 'r' && flags.includes('x')) { + const error = new TypeError(`The argument 'flags' is invalid. Received '${flags}'`) as TypeError & { code: string } + error.code = 'ERR_INVALID_ARG_VALUE' + throw error + } + return { + readable: base === 'r' || flags.includes('+'), + writable: base !== 'r' || flags.includes('+'), + append: base === 'a', + create: base === 'w' || base === 'a', + truncate: base === 'w', + exclusive: flags.includes('x'), + } +} + +/** Resize bytes exactly, preserving the prefix and zero-filling growth. */ +function resize(bytes: Uint8Array, length: number): Uint8Array { + if (!Number.isSafeInteger(length) || length < 0) { + const error = new RangeError(`The value of "len" is out of range. It must be >= 0. Received ${String(length)}`) as RangeError & { code: string } + error.code = 'ERR_OUT_OF_RANGE' + throw error + } + const resized = new Uint8Array(length) + resized.set(bytes.subarray(0, length)) + return resized +} + /** Construction inputs for {@link MemoryVfs}. */ export interface MemoryVfsOptions { /** Durable write-behind observer; absent leaves the filesystem ephemeral. */ @@ -133,9 +185,8 @@ export class MemoryVfs implements Vfs { private readonly mutationListeners = new Set() private readonly sink: VfsMutationSink | undefined private temporaries = 0 - // Identity per path, assigned on first stat and dropped when the path goes: - // the filesystem service builds its version token from `ino` plus the - // timestamp, so a recreated path must not look like the entry it replaced. + // Directories retain path identities. File identities live on FileNode so + // descriptors, renames, and hard links continue to address the same file. private readonly identities = new Map() private lastIdentity = 0n @@ -256,9 +307,9 @@ export class MemoryVfs implements Vfs { : this.directories.has(target) ? [0, this.directoryMtimes.get(target) ?? 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const : fail('ENOENT', 'stat', target) - const identity = this.identityOf(target) + const identity = node === undefined ? this.identityOf(target) : this.identityOfFile(node) return options?.bigint === true - ? bigIntStatsOf(size, mtimeMs, directory, identity, mode) + ? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.pathsOf(node).length) : statsOf(size, mtimeMs, directory, identity, mode) } @@ -276,7 +327,62 @@ export class MemoryVfs implements Vfs { return this.lastIdentity } - /** Forget a removed path's identity, so a recreated path reports a new one. */ + /** @returns The inode-like identity retained by a file node across names. */ + private identityOfFile(node: FileNode): bigint { + if (node.identity !== undefined) return node.identity + this.lastIdentity += 1n + node.identity = this.lastIdentity + return node.identity + } + + /** @returns Every currently linked path for one file node. */ + private pathsOf(node: FileNode): string[] { + const paths: string[] = [] + for (const [path, candidate] of this.files) { + if (candidate === node) paths.push(path) + } + return paths + } + + /** Publish a content or metadata write for every hard link to one node. */ + private publishFile(node: FileNode, appendedFrom?: number): void { + for (const path of this.pathsOf(node)) { + this.publish({ + kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false, + ...appendedFrom === undefined ? {} : { appendedFrom }, + }) + } + } + + /** Replace bytes on one file identity and notify all linked paths. */ + private replaceFile(node: FileNode, bytes: Uint8Array, appendedFrom?: number): void { + node.bytes = bytes + node.mtimeMs = this.touchNode(node) + this.publishFile(node, appendedFrom) + } + + /** Write at one offset, zero-filling any gap. */ + private writeFileNode(node: FileNode, position: number, data: Uint8Array): number { + const offset = Math.max(0, position) + const previousLength = node.bytes.length + const bytes = new Uint8Array(Math.max(previousLength, offset + data.length)) + bytes.set(node.bytes) + bytes.set(data, offset) + this.replaceFile(node, bytes, offset === previousLength ? previousLength : undefined) + return data.length + } + + /** Resize one file identity and notify all linked paths. */ + private truncateFile(node: FileNode, length: number): void { + this.replaceFile(node, resize(node.bytes, length)) + } + + /** @returns Plain stats for an open file, including after its last name is removed. */ + private fileStats(node: FileNode): VfsStats { + return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode) + } + + /** Forget removed directory identities, so recreated paths report new ones. */ private forgetIdentity(target: string): void { this.identities.delete(target) const prefix = `${target}${SEP}` @@ -296,7 +402,12 @@ export class MemoryVfs implements Vfs { * @returns Now, or one millisecond past the entry's current time. */ private touch(target: string): number { - const previous = this.files.get(target)?.mtimeMs + return this.touchNode(this.files.get(target)) + } + + /** @returns A modification time strictly newer than one file node's current value. */ + private touchNode(node?: FileNode): number { + const previous = node?.mtimeMs const now = Date.now() return previous === undefined ? now : Math.max(now, previous + 1) } @@ -402,11 +513,14 @@ export class MemoryVfs implements Vfs { const previous = this.files.get(target) const mode = previous?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE) const bytes = typeof data === 'string' ? encoder.encode(data) : data - this.files.set(target, { bytes, mtimeMs: this.touch(target), mode }) - if (previous === undefined) this.touchDirectory(dirname(target)) - this.publish({ - kind: 'write', path: target, bytes, mode, entryChanged: previous === undefined, - }) + if (previous !== undefined) { + this.replaceFile(previous, bytes) + return + } + const node: FileNode = { bytes, mtimeMs: this.touch(target), mode } + this.files.set(target, node) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'write', path: target, bytes, mode, entryChanged: true }) } /** @@ -453,46 +567,89 @@ export class MemoryVfs implements Vfs { ...this.handleTail(target), } } - const exists = this.files.has(target) - if (flags.startsWith('r') && !exists) fail('ENOENT', 'open', target) - if (flags.startsWith('wx') && exists) fail('EEXIST', 'open', target) - if (!flags.startsWith('r') && !this.directories.has(dirname(target))) fail('ENOENT', 'open', target) - const creation = mode === undefined ? {} : { mode } - if (flags.startsWith('w') && !flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array(), creation) - if (flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array(), { flag: 'wx', ...creation }) - if (flags.startsWith('a') && !exists) this.writeFileSync(target, new Uint8Array(), creation) - const appending = flags.startsWith('a') + const file = this.openFileSync(target, flags, mode) + let position = 0 + let closed = false + const current = (syscall: string): VfsOpenFile => { + if (closed) fail('EBADF', syscall, target) + return file + } return { write: async (data: string | Uint8Array): Promise<{ bytesWritten: number }> => { const bytes = typeof data === 'string' ? encoder.encode(data) : data - this.appendFileSync(target, bytes) - return { bytesWritten: bytes.length } + const descriptor = current('write') + const offset = descriptor.append ? descriptor.stat().size : position + const bytesWritten = descriptor.write(offset, bytes) + position = offset + bytesWritten + return { bytesWritten } }, - // A handle opened for append must append here too: session persistence - // opens the log with `a` and writes each batch through this method, so a - // truncating write would replace the whole log with the newest batch. writeFile: async (data: string | Uint8Array): Promise => { - if (appending) this.appendFileSync(target, data) - else this.writeFileSync(target, data) + const bytes = typeof data === 'string' ? encoder.encode(data) : data + const descriptor = current('write') + const offset = descriptor.append ? descriptor.stat().size : position + position = offset + descriptor.write(offset, bytes) + }, + readFile: async (options?: VfsReadOptions): Promise => { + const descriptor = current('read') + const bytes = descriptor.read(position, Math.max(0, descriptor.stat().size - position)) + position += bytes.length + return encodingOf(options) === undefined ? bytes : decoder.decode(bytes) }, - readFile: async (options?: VfsReadOptions): Promise => this.readFileSync(target, options), truncate: async (length = 0): Promise => { - const node = this.files.get(target) - if (node === undefined) fail('ENOENT', 'ftruncate', target) - const bytes = node.bytes.slice(0, length) - this.files.set(target, { bytes, mtimeMs: this.touch(target), mode: node.mode }) - this.publish({ kind: 'write', path: target, bytes, mode: node.mode, entryChanged: false }) + current('ftruncate').truncate(length) }, - ...this.handleTail(target), + stat: async (): Promise => current('fstat').stat(), + sync: async (): Promise => { current('fsync'); await this.flush() }, + datasync: async (): Promise => { current('fdatasync'); await this.flush() }, + close: async (): Promise => { closed = true }, } } /** - * The handle members that do not depend on how the file was opened. - * + * Open one synchronous descriptor over a stable file identity. + * @param path - File path. + * @param flags - Node open flags. + * @param mode - Permission bits applied only when a file is created. + * @returns An open file that survives path rename, replacement, and unlink. + */ + openFileSync(path: string, flags = 'r', mode?: number): VfsOpenFile { + const target = this.key(path) + const access = openMode(flags) + const existing = this.files.get(target) + if (this.directories.has(target)) fail('EISDIR', 'open', target) + if (access.exclusive && existing !== undefined) fail('EEXIST', 'open', target) + if (!access.create && existing === undefined) fail('ENOENT', 'open', target) + if (access.create && existing === undefined) { + this.writeFileSync(target, new Uint8Array(), mode === undefined ? undefined : { mode }) + } else if (access.truncate && existing !== undefined) { + this.truncateFile(existing, 0) + } + const node = this.files.get(target) + if (node === undefined) fail('ENOENT', 'open', target) + return { + readable: access.readable, + writable: access.writable, + append: access.append, + read: (position, length) => { + if (!access.readable) fail('EBADF', 'read', target) + return node.bytes.subarray(position, position + length) + }, + write: (position, data) => { + if (!access.writable) fail('EBADF', 'write', target) + return this.writeFileNode(node, access.append ? node.bytes.length : position, data) + }, + truncate: (length) => { + if (!access.writable) fail('EINVAL', 'ftruncate', target) + this.truncateFile(node, length) + }, + stat: () => this.fileStats(node), + } + } + + /** + * Directory-handle members for metadata, durability, and release. * `sync`/`datasync` settle an attached durable sink; an ephemeral filesystem - * resolves immediately. `close` releases nothing, so both directory and file - * handles share this tail. + * resolves immediately and `close` releases nothing. * @param target - Normalized path the handle was opened on. * @returns Metadata plus the no-op durability and release calls. */ @@ -515,14 +672,7 @@ export class MemoryVfs implements Vfs { const existing = this.files.get(target) const addition = typeof data === 'string' ? encoder.encode(data) : data if (existing === undefined) { this.writeFileSync(target, addition); return } - const merged = new Uint8Array(existing.bytes.length + addition.length) - merged.set(existing.bytes) - merged.set(addition, existing.bytes.length) - this.files.set(target, { bytes: merged, mtimeMs: this.touch(target), mode: existing.mode }) - this.publish({ - kind: 'write', path: target, bytes: merged, mode: existing.mode, - entryChanged: false, appendedFrom: existing.bytes.length, - }) + this.writeFileNode(existing, existing.bytes.length, addition) } /** @@ -533,8 +683,10 @@ export class MemoryVfs implements Vfs { renameSync(from: string, to: string): void { const source = this.key(from) const destination = this.key(to) + if (source === destination) return const node = this.files.get(source) if (node !== undefined) { + if (this.directories.has(destination)) fail('EISDIR', 'rename', destination) if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination) this.files.delete(source) this.files.set(destination, node) @@ -588,9 +740,8 @@ export class MemoryVfs implements Vfs { /** * Give existing bytes a second name. * - * There are no inodes here, so the two names share the bytes present at link - * time and diverge on the next write through either name; session persistence - * links a finished file to a stable name, which this satisfies. + * Both names retain one file identity, so writes and metadata changes through + * either name remain visible through the other until that name is removed. * @param existing - Source file path. * @param next - Additional path; its parent must exist and it must be free. */ @@ -615,9 +766,7 @@ export class MemoryVfs implements Vfs { const target = this.key(path) const node = this.files.get(target) if (node === undefined) fail('ENOENT', 'truncate', target) - const bytes = node.bytes.slice(0, length) - this.files.set(target, { bytes, mtimeMs: this.touch(target), mode: node.mode }) - this.publish({ kind: 'write', path: target, bytes, mode: node.mode, entryChanged: false }) + this.truncateFile(node, length) } /** @@ -630,7 +779,7 @@ export class MemoryVfs implements Vfs { const node = this.files.get(target) if (node !== undefined) { node.mode = mode & 0o777 - this.publish({ kind: 'chmod', path: target, mode: node.mode }) + for (const path of this.pathsOf(node)) this.publish({ kind: 'chmod', path, mode: node.mode }) return } if (this.directories.has(target)) { diff --git a/packages/experimental/webworker-runtime/src/storage/types.ts b/packages/experimental/webworker-runtime/src/storage/types.ts index e879d5f11f..f1482e07d6 100644 --- a/packages/experimental/webworker-runtime/src/storage/types.ts +++ b/packages/experimental/webworker-runtime/src/storage/types.ts @@ -1,8 +1,8 @@ /** * Filesystem interfaces shared by every VFS backend. The shipped implementation - * is in memory; a browser-persistent backend would implement the same faces. Errors carry - * Node's `code` values because roster plugins branch on them (`ENOENT` for - * optional files, `EACCES` for read-only trees). + * is in memory; browser persistence hydrates it and consumes its committed + * mutation stream. Errors carry Node's `code` values because roster plugins + * branch on them (`ENOENT` for optional files, `EACCES` for read-only trees). * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types */ @@ -22,7 +22,7 @@ export interface VfsError extends Error { /** Subset of `fs.Stats` the roster reads. */ export interface VfsStats { readonly size: number - /** Stable identity while an entry exists; recreation receives another value. */ + /** Stable file identity across rename and hard links; recreation receives another value. */ readonly ino: number readonly mtimeMs: number readonly ctimeMs: number @@ -54,7 +54,7 @@ export interface VfsBigIntStats { readonly mode: bigint /** One virtual device holds the whole image. */ readonly dev: bigint - /** Identity of the entry at this path; a removed and recreated path gets a new one. */ + /** File identity retained across rename and hard links; recreation gets a new one. */ readonly ino: bigint readonly nlink: bigint readonly mtimeMs: bigint @@ -125,6 +125,40 @@ export interface VfsFileHandle { close(): Promise } +/** Open-file identity used by synchronous Node-style descriptors. */ +export interface VfsOpenFile { + /** Whether reads are allowed by the flags used at open time. */ + readonly readable: boolean + /** Whether writes and truncation are allowed by the flags used at open time. */ + readonly writable: boolean + /** Whether each write targets the current end of the opened file. */ + readonly append: boolean + /** + * Read bytes from the opened file identity. + * @param position - Absolute byte offset. + * @param length - Maximum byte count. + * @returns A view of the available bytes. + */ + read(position: number, length: number): Uint8Array + /** + * Write bytes to the opened file identity. + * @param position - Absolute byte offset, ignored for append descriptors. + * @param data - Bytes to write. + * @returns Number of bytes written. + */ + write(position: number, data: Uint8Array): number + /** + * Resize the opened file, zero-filling growth. + * @param length - Target byte length. + */ + truncate(length: number): void + /** + * Read metadata from the opened file identity. + * @returns Current file metadata, including after rename or unlink. + */ + stat(): VfsStats +} + /** * One completed change to the authoritative in-memory filesystem. * @@ -203,6 +237,8 @@ export interface Vfs { unlinkSync(path: string): void rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void mkdtempSync(prefix: string): string + /** Open and retain one file identity until its Node descriptor closes. */ + openFileSync(path: string, flags?: string, mode?: number): VfsOpenFile seed(path: string, data: string | Uint8Array, options?: VfsSeedOptions): void seedDirectory(path: string, options?: VfsSeedOptions): void usage(): { files: number; directories: number; bytes: number } diff --git a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts index e61693e479..3c5304e8df 100644 --- a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts @@ -238,6 +238,14 @@ it('normalizes relative grants and denies sibling-prefix escapes and unreadable expect(result.stdout).not.toContain('private') }) +it('treats trailing-slash grants as the same subtree', async () => { + const invocation = parseLandlockArguments(['--rw', '/tmp/', '--', 'true']) + if (invocation.kind !== 'run') throw new Error('expected a confined run invocation') + const guarded = await landlockFileSystem(hostFileSystem(), invocation, WORKSPACE) + await guarded.writeText('/tmp/nested.txt', 'allowed') + expect(vfs.readFileSync(`${TMP}/nested.txt`, 'utf8')).toBe('allowed') +}) + it('presents the virtual device directory without storing it in the VFS', async () => { const child = spawn(launcherPath(), [ ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }), diff --git a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts index e095d2b6f2..c5759dd4d7 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts @@ -1,12 +1,17 @@ /** Node differential checks for the Worker filesystem watcher and stream faces. */ import { + closeSync as closeNodeSync, createReadStream as createNodeReadStream, createWriteStream as createNodeWriteStream, mkdtempSync, + openSync as openNodeSync, + readSync as readNodeSync, readFileSync, + renameSync as renameNodeSync, rmSync, unwatchFile as unwatchNodeFile, watchFile as watchNodeFile, + writeSync as writeNodeSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -106,6 +111,139 @@ async function writeScenario(create: () => WritableFileStream): Promise<{ } describe('file streams', () => { + it('keeps an opened file identity across rename, replacement, and unlink', () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'identity.txt') + const workerPath = `${VFS_ROOT}/identity.txt` + + const nativeScenario = (): string[] => { + writeFileSync(nativePath, 'original') + const fd = openNodeSync(nativePath, 'r') + renameNodeSync(nativePath, `${nativePath}.moved`) + writeFileSync(nativePath, 'replacement') + const beforeUnlink = Buffer.alloc(16) + const firstCount = readNodeSync(fd, beforeUnlink, 0, beforeUnlink.length, 0) + rmSync(`${nativePath}.moved`) + const afterUnlink = Buffer.alloc(16) + const secondCount = readNodeSync(fd, afterUnlink, 0, afterUnlink.length, 0) + closeNodeSync(fd) + return [beforeUnlink.subarray(0, firstCount).toString(), afterUnlink.subarray(0, secondCount).toString()] + } + const workerScenario = (): string[] => { + vfs.writeFileSync(workerPath, 'original') + const fd = workerFs.openSync(workerPath, 'r') + vfs.renameSync(workerPath, `${workerPath}.moved`) + vfs.writeFileSync(workerPath, 'replacement') + const beforeUnlink = Buffer.alloc(16) + const firstCount = workerFs.readSync(fd, beforeUnlink, 0, beforeUnlink.length, 0) + vfs.rmSync(`${workerPath}.moved`) + const afterUnlink = Buffer.alloc(16) + const secondCount = workerFs.readSync(fd, afterUnlink, 0, afterUnlink.length, 0) + workerFs.closeSync(fd) + return [beforeUnlink.subarray(0, firstCount).toString(), afterUnlink.subarray(0, secondCount).toString()] + } + + expect(workerScenario()).toEqual(nativeScenario()) + }) + + it('keeps a read stream on the file opened before an atomic replacement', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'stream-identity.txt') + const workerPath = `${VFS_ROOT}/stream-identity.txt` + writeFileSync(nativePath, 'original') + vfs.writeFileSync(workerPath, 'original') + + const readAfterReplacement = async ( + stream: AsyncIterable & { once(event: string, listener: () => void): unknown }, + replace: () => void, + ): Promise => { + stream.once('open', replace) + const chunks: Uint8Array[] = [] + for await (const chunk of stream) chunks.push(chunk) + return Buffer.concat(chunks).toString() + } + const native = await readAfterReplacement(createNodeReadStream(nativePath, { highWaterMark: 2 }), () => { + renameNodeSync(nativePath, `${nativePath}.moved`) + writeFileSync(nativePath, 'replacement') + }) + const worker = await readAfterReplacement(workerFs.createReadStream(workerPath, { highWaterMark: 2 }), () => { + vfs.renameSync(workerPath, `${workerPath}.moved`) + vfs.writeFileSync(workerPath, 'replacement') + }) + expect(worker).toBe(native) + }) + + it('rejects descriptor operations that conflict with the open mode', () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'mode.txt') + const workerPath = `${VFS_ROOT}/mode.txt` + writeFileSync(nativePath, 'content') + vfs.writeFileSync(workerPath, 'content') + const codeOf = (run: () => unknown): string | undefined => { + try { + run() + return undefined + } catch (error) { + return (error as NodeJS.ErrnoException).code + } + } + + const nativeReadOnly = openNodeSync(nativePath, 'r') + const workerReadOnly = workerFs.openSync(workerPath, 'r') + expect(codeOf(() => workerFs.writeSync(workerReadOnly, 'x'))) + .toBe(codeOf(() => writeNodeSync(nativeReadOnly, 'x'))) + closeNodeSync(nativeReadOnly) + workerFs.closeSync(workerReadOnly) + + const nativeWriteOnly = openNodeSync(nativePath, 'w') + const workerWriteOnly = workerFs.openSync(workerPath, 'w') + expect(codeOf(() => workerFs.readSync(workerWriteOnly, Buffer.alloc(1), 0, 1, 0))) + .toBe(codeOf(() => readNodeSync(nativeWriteOnly, Buffer.alloc(1), 0, 1, 0))) + closeNodeSync(nativeWriteOnly) + workerFs.closeSync(workerWriteOnly) + }) + + it('keeps hard-link identity and content shared through the Node face', () => { + const source = `${VFS_ROOT}/linked-source.txt` + const alias = `${VFS_ROOT}/linked-alias.txt` + workerFs.writeFileSync(source, 'one') + workerFs.linkSync(source, alias) + expect(workerFs.statSync(alias, { bigint: true }).ino) + .toBe(workerFs.statSync(source, { bigint: true }).ino) + workerFs.appendFileSync(alias, '-two') + expect(workerFs.readFileSync(source, 'utf8')).toBe('one-two') + }) + + it('reports incompatible read and write stream flags as EBADF', async () => { + const path = `${VFS_ROOT}/stream-mode.txt` + vfs.writeFileSync(path, 'content') + const writeError = nextValue((resolve) => { + const stream = workerFs.createWriteStream(path, { flags: 'r' }) + stream.once('error', resolve) + stream.end('x') + }) + await expect(writeError).resolves.toMatchObject({ code: 'EBADF' }) + + const read = workerFs.createReadStream(path, { flags: 'w' }) + const readError = nextValue((resolve) => { read.once('error', resolve) }) + read.resume() + await expect(readError).resolves.toMatchObject({ code: 'EBADF' }) + }) + + it('zero-extends through promise and file-handle truncate', async () => { + const path = `${VFS_ROOT}/truncate.txt` + vfs.writeFileSync(path, new Uint8Array([1, 2])) + await workerFsp.truncate(path, 4) + expect([...workerFs.readFileSync(path) as Uint8Array]).toEqual([1, 2, 0, 0]) + const handle = await workerFsp.open(path, 'r+') + await handle.truncate(6) + await handle.close() + expect([...workerFs.readFileSync(path) as Uint8Array]).toEqual([1, 2, 0, 0, 0, 0]) + }) + it('matches Node chunking, inclusive ranges, and read lifecycle ordering', async () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) nativeRoots.push(nativeRoot) @@ -175,6 +313,51 @@ describe('file streams', () => { expect(error).toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) }) + it('keeps autoClose false descriptors open until explicit stream close', async () => { + const readPath = `${VFS_ROOT}/manual-read-close.txt` + vfs.writeFileSync(readPath, 'content') + const read = workerFs.createReadStream(readPath, { autoClose: false }) + read.resume() + await nextValue((resolve) => { read.once('end', () => { resolve(undefined) }) }) + const readFd = read.fd + expect(readFd).not.toBeNull() + expect(read.destroyed).toBe(false) + expect(() => workerFs.readSync(readFd as number, Buffer.alloc(1), 0, 1, 0)).not.toThrow() + const readClosed = nextValue((resolve) => { read.once('close', () => { resolve(undefined) }) }) + read.close() + await readClosed + expect(() => workerFs.readSync(readFd as number, Buffer.alloc(1), 0, 1, 0)).toThrow(/EBADF/) + + const write = workerFs.createWriteStream(`${VFS_ROOT}/manual-write-close.txt`, { autoClose: false }) + write.end('a') + await nextValue((resolve) => { write.once('finish', () => { resolve(undefined) }) }) + const writeFd = write.fd + expect(writeFd).not.toBeNull() + expect(write.destroyed).toBe(false) + expect(workerFs.writeSync(writeFd as number, 'b')).toBe(1) + const writeClosed = nextValue((resolve) => { write.once('close', () => { resolve(undefined) }) }) + write.close() + await writeClosed + expect(workerFs.readFileSync(`${VFS_ROOT}/manual-write-close.txt`, 'utf8')).toBe('ab') + + vfs.writeFileSync(`${VFS_ROOT}/manual-error-close.txt`, 'content') + const errored = workerFs.createWriteStream(`${VFS_ROOT}/manual-error-close.txt`, { + flags: 'r', + autoClose: false, + }) + const error = nextValue((resolve) => { errored.once('error', resolve) }) + errored.end('rejected') + await expect(error).resolves.toMatchObject({ code: 'EBADF' }) + const errorFd = errored.fd + expect(errorFd).not.toBeNull() + expect(errored.destroyed).toBe(false) + expect(() => workerFs.readSync(errorFd as number, Buffer.alloc(1), 0, 1, 0)).not.toThrow() + const errorClosed = nextValue((resolve) => { errored.once('close', () => { resolve(undefined) }) }) + errored.destroy() + await errorClosed + expect(() => workerFs.readSync(errorFd as number, Buffer.alloc(1), 0, 1, 0)).toThrow(/EBADF/) + }) + it('matches Node positional overwrite and missing-file failure', async () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) nativeRoots.push(nativeRoot) @@ -258,6 +441,22 @@ async function watchFileScenario( } describe('watchers', () => { + it('does not catch exceptions thrown by a successful stat callback', () => { + const path = `${VFS_ROOT}/callback.txt` + vfs.writeFileSync(path, 'value') + const failure = new Error('callback failed') + let calls = 0 + const dispatch = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((callback) => { callback() }) + expect(() => { + workerFs.stat(path, () => { + calls += 1 + throw failure + }) + }).toThrow(failure) + expect(calls).toBe(1) + dispatch.mockRestore() + }) + it('matches Node watchFile state transitions for a missing and recreated file', async () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-watch-diff-')) nativeRoots.push(nativeRoot) @@ -398,16 +597,20 @@ describe('watchers', () => { await expect(event).resolves.toEqual(['rename', 'file.txt']) }) - it('rejects an already-aborted callback watcher without retaining a subscription', () => { + it('returns an asynchronously closing watcher for a pre-aborted signal', async () => { const controller = new AbortController() - const reason = new Error('already stopped') - controller.abort(reason) - try { - workerFs.watch(VFS_ROOT, { signal: controller.signal }) - throw new Error('watch unexpectedly opened') - } catch (error) { - expect(error).toMatchObject({ name: 'AbortError', code: 'ABORT_ERR', cause: reason }) - } + controller.abort(new Error('already stopped')) + const order: string[] = [] + const watcher = workerFs.watch(VFS_ROOT, { signal: controller.signal }) + const closed = nextValue((resolve) => { + watcher.once('close', () => { + order.push('close') + resolve(undefined) + }) + }) + order.push('return') + await closed + expect(order).toEqual(['return', 'close']) expect(() => { vfs.writeFileSync(`${VFS_ROOT}/after-abort.txt`, 'x') }).not.toThrow() }) @@ -474,6 +677,14 @@ describe('watchers', () => { await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) }) + it('rejects the first promise-watch read for a pre-aborted signal', async () => { + const controller = new AbortController() + const reason = new Error('already stopped') + controller.abort(reason) + const iterator = workerFsp.watch(VFS_ROOT, { signal: controller.signal })[Symbol.asyncIterator]() + await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR', cause: reason }) + }) + it('lets promise-watch return interrupt a pending next call', async () => { const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() const pending = iterator.next() diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index 8278e6c153..069da0ca2a 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -40,10 +40,7 @@ describe('entry identity', () => { expect(identity(vfs, '/dsh/skills/git/SKILL.md')).not.toBe(before) }) - it('assigns the destination of a rename an identity of its own', () => { - // Identity belongs to the path, not to the bytes: a renamed-over path must - // stop looking like the entry it replaced, which is the property the guard - // reads. The source identity deliberately does not follow the move. + it('moves the source identity when a file replaces another path', () => { const vfs = new MemoryVfs() vfs.seed('/dsh/from.txt', 'moved') vfs.seed('/dsh/to.txt', 'replaced') @@ -51,7 +48,7 @@ describe('entry identity', () => { vfs.renameSync('/dsh/from.txt', '/dsh/to.txt') const renamed = identity(vfs, '/dsh/to.txt') expect(vfs.readFileSync('/dsh/to.txt', 'utf8')).toBe('moved') - expect([renamed === source, renamed === destination]).toEqual([false, false]) + expect([renamed === source, renamed === destination]).toEqual([true, false]) }) }) @@ -92,6 +89,16 @@ describe('modification time', () => { expect(modified(vfs, '/dsh/log.jsonl')).toBe(1_700_000_005_000) }) + it('extends truncation with zero bytes', async () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/file', new Uint8Array([1, 2])) + vfs.truncateSync('/dsh/file', 5) + expect([...vfs.readFileSync('/dsh/file') as Uint8Array]).toEqual([1, 2, 0, 0, 0]) + const handle = vfs.open('/dsh/file', 'r+') + await handle.truncate(7) + expect([...vfs.readFileSync('/dsh/file') as Uint8Array]).toEqual([1, 2, 0, 0, 0, 0, 0]) + }) + it('advances a directory only when its immediate entry set changes', () => { vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) const vfs = new MemoryVfs() @@ -175,6 +182,24 @@ describe('mutation publication', () => { expect(flushes).toBe(1) }) + it('publishes descriptor writes at the file identity current path', () => { + const mutations: VfsMutation[] = [] + const vfs = new MemoryVfs() + vfs.seed('/dsh/source', 'old') + const descriptor = vfs.openFileSync('/dsh/source', 'r+') + vfs.subscribe((mutation) => { mutations.push(mutation) }) + vfs.renameSync('/dsh/source', '/dsh/destination') + mutations.length = 0 + descriptor.write(0, new TextEncoder().encode('new')) + expect(mutations.map(mutation => mutation.path)).toEqual(['/dsh/destination']) + expect(vfs.readFileSync('/dsh/destination', 'utf8')).toBe('new') + vfs.unlinkSync('/dsh/destination') + mutations.length = 0 + descriptor.write(0, new TextEncoder().encode('detached')) + expect(mutations).toEqual([]) + expect(new TextDecoder().decode(descriptor.read(0, descriptor.stat().size))).toBe('detached') + }) + it('decomposes a directory rename into replayable destination state', () => { const recorded: VfsMutation[] = [] const vfs = new MemoryVfs({ @@ -196,13 +221,30 @@ describe('mutation publication', () => { }) describe('hard links', () => { - it('shares the bytes present at link time and diverges on the next write', () => { + it('shares identity, bytes, and mode until one name is removed', () => { const vfs = new MemoryVfs() vfs.seed('/dsh/session.jsonl', 'committed\n') vfs.linkSync('/dsh/session.jsonl', '/dsh/session-latest.jsonl') + expect(identity(vfs, '/dsh/session-latest.jsonl')).toBe(identity(vfs, '/dsh/session.jsonl')) expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\n') + const changedPaths: string[] = [] + vfs.subscribe((mutation) => { changedPaths.push(mutation.path) }) vfs.appendFileSync('/dsh/session.jsonl', 'appended\n') + expect(changedPaths).toEqual(['/dsh/session.jsonl', '/dsh/session-latest.jsonl']) expect(vfs.readFileSync('/dsh/session.jsonl', 'utf8')).toBe('committed\nappended\n') - expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\n') + expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\nappended\n') + vfs.chmodSync('/dsh/session-latest.jsonl', 0o600) + expect((vfs.statSync('/dsh/session.jsonl') as VfsStats).mode & 0o777).toBe(0o600) + vfs.unlinkSync('/dsh/session-latest.jsonl') + expect(vfs.readFileSync('/dsh/session.jsonl', 'utf8')).toBe('committed\nappended\n') + }) + + it('rejects renaming a file over an existing directory', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/file', 'value') + vfs.seedDirectory('/dsh/directory') + expect(() => { vfs.renameSync('/dsh/file', '/dsh/directory') }).toThrow(expect.objectContaining({ code: 'EISDIR' })) + expect(vfs.readFileSync('/dsh/file', 'utf8')).toBe('value') + expect(vfs.statSync('/dsh/directory').isDirectory()).toBe(true) }) }) From be852d4e9bd2adb4f1d317bc1f488533e1a5a62a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:01:19 +0800 Subject: [PATCH 023/138] fix(webworker): scope Linux-only CI checks --- .../node/builtin_modules/implemented/fs.ts | 57 ++++++++++++------- .../tests/node/fs-watch-stream.spec.ts | 2 +- vitest.config.ts | 17 +++++- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index 4e83d52752..e4d7099f3e 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -481,6 +481,40 @@ export interface WriteStreamOptions { /** Node implements file-stream `autoClose` through the stream's `autoDestroy` state. */ const streamAutoDestroy = (autoClose: boolean | undefined): boolean => autoClose ?? true +interface FileStreamState { + fd: number | null + pending: boolean +} + +/** Release the descriptor and abort listener shared by both file-stream directions. */ +function destroyFileStream( + stream: FileStreamState, + signal: AbortSignal | undefined, + onAbort: (() => void) | undefined, + error: Error | null, + callback: (error: Error | null) => void, +): void { + signal?.removeEventListener('abort', onAbort as () => void) + if (stream.fd !== null) closeSync(stream.fd) + stream.fd = null + stream.pending = false + callback(error) +} + +interface ClosableFileStream { + once(event: string, listener: () => void): unknown + destroy(): unknown +} + +/** Register an optional completion callback and explicitly destroy a file stream. */ +function closeFileStream( + stream: ClosableFileStream, + callback?: (error?: NodeJS.ErrnoException | null) => void, +): void { + if (callback !== undefined) stream.once('close', () => { callback(null) }) + stream.destroy() +} + /** Read stream over one VFS file. */ export class ReadStream extends Readable { /** Resolved path opened by this stream. */ @@ -560,11 +594,7 @@ export class ReadStream extends Readable { } override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { - this.signal?.removeEventListener('abort', this.onAbort as () => void) - if (this.fd !== null) closeSync(this.fd) - this.fd = null - this.pending = false - callback(error) + destroyFileStream(this, this.signal, this.onAbort, error, callback) } /** @@ -572,8 +602,7 @@ export class ReadStream extends Readable { * @param callback - Optional completion callback after `close`. */ close(callback?: (error?: NodeJS.ErrnoException | null) => void): void { - if (callback !== undefined) this.once('close', () => { callback(null) }) - this.destroy() + closeFileStream(this, callback) } } @@ -647,11 +676,7 @@ export class WriteStream extends Writable { } override _destroy(error: Error | null, callback: (error: Error | null) => void): void { - this.signal?.removeEventListener('abort', this.onAbort as () => void) - closeDescriptor(this.fd) - this.fd = null - this.pending = false - callback(error) + destroyFileStream(this, this.signal, this.onAbort, error, callback) } /** @@ -659,16 +684,10 @@ export class WriteStream extends Writable { * @param callback - Optional completion callback after `close`. */ close(callback?: (error?: NodeJS.ErrnoException | null) => void): void { - if (callback !== undefined) this.once('close', () => { callback(null) }) - this.destroy() + closeFileStream(this, callback) } } -/** Close a stream-owned descriptor when it has opened successfully. */ -function closeDescriptor(fd: number | null): void { - if (fd !== null) closeSync(fd) -} - /** * Create a Node-compatible readable file stream over the VFS. * @param path - File path. diff --git a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts index c5759dd4d7..df8c31e0b5 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts @@ -279,7 +279,7 @@ describe('file streams', () => { expect(workerStream.default._isArrayBufferView(new Uint8Array())).toBe(true) }) - it('matches Node file-stream defaults and abort error identity', async () => { + it('uses Node 22 Linux file-stream defaults and abort error identity', async () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) nativeRoots.push(nativeRoot) const nativePath = join(nativeRoot, 'input.txt') diff --git a/vitest.config.ts b/vitest.config.ts index f24c8c552e..6e870fcbe4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,6 +57,17 @@ const windowsUnsupportedTests = process.platform === 'win32' ] : [] +// These suites compare against or assemble the Worker's fixed Linux platform. +// Host-native Windows and macOS behavior is not their oracle. +const nonLinuxWebWorkerTests = process.platform === 'linux' + ? [] + : [ + 'packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts', + 'packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts', + ] + +const platformUnsupportedTests = [...windowsUnsupportedTests, ...nonLinuxWebWorkerTests] + const windowsUnsupportedCoveragePackages = process.platform === 'win32' ? [...windowsUnsupportedPackages, 'packages/subprocess/*'] : [] @@ -142,7 +153,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, - exclude: windowsUnsupportedTests, + exclude: platformUnsupportedTests, // One coverage invocation aggregates both projects. Every suite forks for // Node stability; process-bound suites stay separate for inventory control. projects: [ @@ -158,7 +169,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ - ...windowsUnsupportedTests, + ...platformUnsupportedTests, ...processBoundTests, ...coverageExemptExcludes, ], @@ -173,7 +184,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, exclude: [ - ...windowsUnsupportedTests, + ...platformUnsupportedTests, ...coverageExemptExcludes, ], }, From 8aa222a40d7952699bf6287fc1bb387bea19c0ec Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:12:27 +0800 Subject: [PATCH 024/138] test(web): await subagent history before snapshot --- apps/web/tests/subagent-interrupt-ui.e2e.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 592d55abe6..0eca3016fd 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -203,6 +203,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co name: 'Parent session offline; sending is unavailable but you can still stop the run', }) await input.waitFor({ timeout: 15_000 }) + await page.getByText(INITIAL, { exact: true }).waitFor({ timeout: 15_000 }) expect(await input.isDisabled()).toBe(true) const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) @@ -247,7 +248,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open') expect(scaffold.ctx.agents.get(childId)?.status).toBe('running') } finally { - await page.unroute(pattern) + await page.unrouteAll({ behavior: 'wait' }) } }, 60_000) From 91b545daf52166e8ce6215d3aa279c764c84e31d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:31:20 +0800 Subject: [PATCH 025/138] fix(webworker): support package inventory resolution --- .../2026-08-20-webworker-node-face.i18n.yaml | 4 +- .../2026-08-20-webworker-node-face.md | 2 +- .../2026-08-20-webworker-node-face.zh.md | 2 +- .../tests/image-loadable.spec.ts | 66 +++++++++++++++++++ .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../src/module-system/module-loader.ts | 53 +++++++++++---- .../builtin_modules/implemented/module.ts | 7 +- .../tests/node/builtins-table.spec.ts | 12 +++- 10 files changed, 127 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml index 47269a11c8..bf06cd8dee 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.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-20-webworker-node-face.md -2026-08-20-webworker-node-face.md: 6e69af83354f1139a03d047a700e84e7e918a013 -2026-08-20-webworker-node-face.zh.md: 96a60e459372828273b3a4d1330a7b7eb8f2994f +2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81 +2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md index 6e69af8335..41a30dedc7 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md @@ -10,7 +10,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work ## Decision -**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. The worker installs the `process` global itself and fills it into the table at assembly. +**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly. **VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md index 96a60e4593..b574813358 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md @@ -10,7 +10,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属 ## 决定 -**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。`process` 全局由 worker 自装,装配期填入表中。 +**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。 **VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。 diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index 609e3ccf80..766f974735 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -20,12 +20,14 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { FiberState } from '@deepseek-ai/cordis' import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts' import { setActiveModuleLoader, WorkerModuleLoader, } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts' import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' +import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts' import { indexWorkspacePackages, previewFixtures } from '../src/repository.ts' import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, packVfsOverlay } from '../src/pack.ts' @@ -34,6 +36,7 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) /** A leaf workspace package: real build output, no dependencies to drag in. */ const SUBJECT = '@deepseek-ai/dsh-timeout' const LANDLOCK = '@deepseek-ai/node-addon-landlock-run' +const PLUGIN_INVENTORY = '@deepseek-ai/dsh-plugin-package-inventory-deepseek' const workspaces = indexWorkspacePackages(repoRoot) @@ -91,6 +94,15 @@ const packedLandlock = (): ReturnType => landlockMemo ??= p entries: [], }) +let pluginInventoryMemo: ReturnType | undefined +const packedPluginInventory = (): ReturnType => pluginInventoryMemo ??= packVfsImage({ + config: `- id: subject\n name: '${PLUGIN_INVENTORY}'\n`, + profile: 'plugin-inventory-check', + workspaces, + resolveFrom: repoRoot, + entries: [], +}) + /** The image's archive, inflated once: mounting reads the tar, not the gzip member. */ let archiveMemo: Uint8Array | undefined const archive = async (): Promise => @@ -191,6 +203,7 @@ const archive = async (): Promise => staticModules: createNodeBuiltins(), staticModulePrefixes: REPLACED_PREFIXES, }) + setActiveVfs(vfs) setActiveModuleLoader(loader) const landlock = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(LANDLOCK) as { LAUNCHER_BIN: string @@ -211,6 +224,59 @@ const archive = async (): Promise => expect(landlock.probe()).toBe('full') }) + it('prepares the unchanged plugin-package inventory through Worker createRequire paths', async () => { + const result = packedPluginInventory() + expect(result.missing).toEqual([]) + + const vfs = loadVfsImage(await inflateImage(result.image, 'the packed plugin inventory'), DEFAULT_ROOT) + const loader = new WorkerModuleLoader({ + vfs, + root: DEFAULT_ROOT, + staticModules: createNodeBuiltins(), + staticModulePrefixes: REPLACED_PREFIXES, + }) + setActiveVfs(vfs) + setActiveModuleLoader(loader) + const inventory = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(PLUGIN_INVENTORY) as { + apply(ctx: unknown, config: unknown): void + } + + type Prepared = { readonly value: { readonly version: number; readonly packages: readonly unknown[] } } + type Prepare = (request: { readonly body: object; readonly signal: AbortSignal }) => Promise + let prepare: Prepare | undefined + const baseUrl = `file://${DEFAULT_ROOT}/config/cordis.yml` + const tree: { readonly ctx: { readonly baseUrl: string }; entries(): readonly unknown[] } = { + ctx: { baseUrl }, + entries: () => [entry], + } + const entry = { + options: { name: PLUGIN_INVENTORY }, + disabled: false, + fiber: { state: FiberState.ACTIVE }, + parent: { tree }, + } + inventory.apply({ + baseUrl, + loader: tree, + deepseekLlmApiExtensions: { + register: (field: string, contribution: { readonly prepare: Prepare }): void => { + expect(field).toBe('dsh_plugin_packages') + prepare = contribution.prepare + }, + }, + }, {}) + + if (prepare === undefined) throw new Error('packed plugin inventory did not register its request contribution') + const prepared = await prepare({ body: {}, signal: new AbortController().signal }) + const manifest = JSON.parse(vfs.readFileSync( + `${DEFAULT_ROOT}/node_modules/${PLUGIN_INVENTORY}/package.json`, 'utf8', + ) as string) as { version: string } + expect(prepared.value).toEqual({ + version: 1, + packages: [{ name: PLUGIN_INVENTORY, version: manifest.version }], + }) + }) + it('refuses a body the packer did not lower, naming the image', async () => { // The case above only proves the packed bytes are wrappable. This is the // other half: the loader has no transform to fall back on, so an entry the diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 9b012cd360..d0d0d13a6e 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: df6dacad35273f8c636a86c7c260b4f5d1958a6a -README.zh.md: 721770149c04169d813d2b5d3d4faafdccf1dec5 +README.md: 3e9b4fffe0b97a97adf218aa12fd1f4342d3bc6c +README.zh.md: 2552c659d1b735b0cf28b9b0d0808276d31d0a2a diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index df6dacad35..3e9b4fffe0 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -7,7 +7,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 721770149c..2552c659d1 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -7,7 +7,7 @@ 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 diff --git a/packages/experimental/webworker-runtime/src/module-system/module-loader.ts b/packages/experimental/webworker-runtime/src/module-system/module-loader.ts index b1d0f70f4a..88cdc39ece 100644 --- a/packages/experimental/webworker-runtime/src/module-system/module-loader.ts +++ b/packages/experimental/webworker-runtime/src/module-system/module-loader.ts @@ -1,8 +1,8 @@ /** * CommonJS module loader over the worker VFS. It fills the `loader.internal` * seam Cordis uses for every entry import, and backs the `node:module` - * `createRequire` proxy that `typert-loader` and `client-modules` resolve - * package metadata through. + * `createRequire` proxy that `typert-loader`, `client-modules`, and the plugin + * package inventory resolve package metadata through. * * Resolution is a narrowed Node `require` algorithm: `exports` walk with a * fixed condition order, extension probing, and one cache keyed by resolved @@ -49,10 +49,26 @@ interface ModuleRecord { readonly module: { exports: unknown } } +/** Resolution helpers carried by a Worker-backed CommonJS require. */ +export interface WorkerRequireResolve { + /** + * Resolve one specifier without evaluating its module. + * @param specifier - Module request relative to the require base. + * @returns Static or VFS-backed module identity. + */ + (specifier: string): string + /** + * Return the directories this loader's Node-style package discovery searches. + * @param specifier - Module request whose lookup roots are requested. + * @returns Search roots, or null for a Worker-provided module. + */ + paths(specifier: string): string[] | null +} + /** The `require` function shape the roster consumes through `createRequire`. */ export interface WorkerRequire { (specifier: string): unknown - resolve(specifier: string): string + readonly resolve: WorkerRequireResolve } /** Construction inputs for {@link WorkerModuleLoader}. */ @@ -226,6 +242,16 @@ export class WorkerModuleLoader { return this.fail(`cannot resolve "${specifier}": no file at ${candidates.join(', ')}`) } + /** @returns The Worker-provided implementation of a static specifier. */ + private staticModule(specifier: string): StaticModuleFactory | undefined { + const exact = this.staticModules.get(specifier) + if (exact !== undefined) return exact + for (const [prefix, factory] of this.staticPrefixes) { + if (specifier.startsWith(prefix)) return factory + } + return this.staticModules.get(`node:${specifier}`) + } + /** * Resolve a specifier the way the module that requested it would. * @param specifier - Bare name, relative path, absolute path, or file URL. @@ -233,11 +259,8 @@ export class WorkerModuleLoader { * @returns Static module or the resolved VFS path. */ resolve(specifier: string, fromDirectory: string): Resolution { - const exact = this.staticModules.get(specifier) - if (exact !== undefined) return { kind: 'static', specifier, factory: exact } - for (const [prefix, factory] of this.staticPrefixes) { - if (specifier.startsWith(prefix)) return { kind: 'static', specifier, factory } - } + const staticModule = this.staticModule(specifier) + if (staticModule !== undefined) return { kind: 'static', specifier, factory: staticModule } if (specifier.startsWith('cordis:') || specifier.startsWith('node:')) { return this.fail(`no static module is registered for "${specifier}"`) } @@ -250,9 +273,6 @@ export class WorkerModuleLoader { if (isAbsolute(specifier)) { return { kind: 'file', path: this.probe(specifier, specifier) } } - // Node resolves `fs` and `node:fs` to the same builtin; the proxy table may register either. - const prefixed = this.staticModules.get(`node:${specifier}`) - if (prefixed !== undefined) return { kind: 'static', specifier, factory: prefixed } const segments = specifier.split('/') const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] ?? specifier const rest = specifier.slice(packageName.length).replace(/^\//, '') @@ -356,15 +376,20 @@ export class WorkerModuleLoader { * @returns Callable require with `resolve`. */ requireFrom(fromDirectory: string): WorkerRequire { - const require = ((specifier: string): unknown => this.load(this.resolve(specifier, fromDirectory))) as WorkerRequire - require.resolve = (specifier: string): string => { + const require = (specifier: string): unknown => this.load(this.resolve(specifier, fromDirectory)) + const resolve = ((specifier: string): string => { const resolution = this.resolve(specifier, fromDirectory) if (resolution.kind === 'static') { return this.fail(`"${specifier}" is a worker-provided module and has no VFS path`) } return resolution.path + }) as WorkerRequireResolve + resolve.paths = (specifier: string): string[] | null => { + if (this.staticModule(specifier) !== undefined || specifier.startsWith('node:')) return null + if (specifier.startsWith('.')) return [resolvePath(fromDirectory, '.')] + return [join(this.root, 'node_modules')] } - return require + return Object.assign(require, { resolve }) } /** diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts index bf77f12bf6..8ad91b49cd 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts @@ -1,7 +1,8 @@ /** * `node:module` for the worker: `createRequire` hands out the worker module - * loader's synchronous require, so typert's `require.resolve('/package.json') - * + readFileSync + import()` bypass runs unmodified over the VFS. + * loader's synchronous require. Typert can resolve package exports, and package + * inventory can discover manifests through `require.resolve.paths()` without + * either consumer changing for the Worker. */ import { requireActiveModuleLoader, type WorkerRequire } from '../../../module-system/module-loader.ts' @@ -11,7 +12,7 @@ export type NodeRequire = WorkerRequire /** * Build a `require` bound to a base path or file URL. * @param base - directory, file path, or file URL the resolution starts from. - * @returns the synchronous require face. + * @returns the synchronous require face, including `resolve()` and `resolve.paths()`. */ export function createRequire(base: string | URL): NodeRequire { return requireActiveModuleLoader().createRequire(base) diff --git a/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts b/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts index b302bbc382..3283f9387e 100644 --- a/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts @@ -15,11 +15,11 @@ */ import { describe, expect, it } from 'vitest' import { createNodeBuiltins, REPLACED_PREFIXES } from '../../src/node/builtins.ts' -import { WorkerModuleLoader } from '../../src/module-system/module-loader.ts' +import { WorkerModuleLoader, type WorkerRequire } from '../../src/module-system/module-loader.ts' import { MemoryVfs } from '../../src/storage/memory.ts' /** A loader over an empty image: every specifier below resolves from the table. */ -function loaderRequire(): (specifier: string) => unknown { +function loaderRequire(): WorkerRequire { const vfs = new MemoryVfs() vfs.seedDirectory('/dsh') const loader = new WorkerModuleLoader({ vfs, root: '/dsh', staticModules: createNodeBuiltins() }) @@ -84,4 +84,12 @@ describe('module identity through the loader', () => { const require = loaderRequire() expect(() => require('node:dns')).toThrow() }) + + it('exposes the package search paths used by the VFS resolver', () => { + const require = loaderRequire() + expect(require.resolve.paths('node:fs')).toBeNull() + expect(require.resolve.paths('node:dns')).toBeNull() + expect(require.resolve.paths('workspace-package')).toEqual(['/dsh/node_modules']) + expect(require.resolve.paths('./local.js')).toEqual(['/dsh']) + }) }) From 5549b9add532ddf432aa52f1c9422d1eda3ff2e2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:32:10 +0800 Subject: [PATCH 026/138] fix(client): preload injected module factories --- .../client/modules/src/client/manifest.ts | 14 ++++++----- packages/client/modules/src/client/system.ts | 16 ++++++++++--- .../modules/tests/loader.client.spec.ts | 24 +++++++++++++++---- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index e5aff58f5a..ba0e521295 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -42,11 +42,10 @@ declare module '@deepseek-ai/cordis' { /** * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. - * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's `dsh.client` - * declaration and reach fibers through entry creation). `external` carries - * module-graph edges: unlike `inject`, they constrain code arrival because - * `require` is synchronous (see {@link WebBootGraph.entries}). + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). */ export interface WebBootEntry { /** Entry name == package name. */ @@ -55,7 +54,7 @@ export interface WebBootEntry { url: string /** Bundle content hash (cache-busting consistency anchor). */ rev: string - /** Package-name dependency edges, informational (preflight display / HMR diffing). */ + /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ immediately?: boolean @@ -83,6 +82,8 @@ export interface BootModuleRow { url: string /** Bundle content hash. */ rev: string + /** Injected package rows whose factories arrive before this row materializes. */ + inject: string[] /** Module specifiers this row requests from the module table ([] when the wire omits them). */ external: string[] } @@ -176,6 +177,7 @@ export function parseBootManifest(wire: unknown): BootManifest { id: row.id, url: row.url, rev: row.rev, + inject: inject === undefined ? [] : [...inject], external: external === undefined ? [] : [...external], }) plugins.push({ diff --git a/packages/client/modules/src/client/system.ts b/packages/client/modules/src/client/system.ts index d4cbb5597b..4dd5311378 100644 --- a/packages/client/modules/src/client/system.ts +++ b/packages/client/modules/src/client/system.ts @@ -124,8 +124,12 @@ export class ClientModuleSystem implements ClientModuleLoader { return task } - /** Register each unresolved dynamic request before registering its consumer. */ - private async arriveGraphRow(row: BootModuleRow, open: readonly string[] = []): Promise { + /** Register each injected package and unresolved dynamic request before its consumer. */ + private async arriveGraphRow( + row: BootModuleRow, + open: readonly string[] = [], + visited = new Set(), + ): Promise { const cycleStart = open.indexOf(row.id) if (cycleStart !== -1) { throw new Error( @@ -133,12 +137,18 @@ export class ClientModuleSystem implements ClientModuleLoader { + '(the host must reject this graph before serving it)', ) } + if (visited.has(row.id)) return + visited.add(row.id) const next = [...open, row.id] for (const request of row.external) { const id = stripClientSuffix(request) if (this.seed.has(request) || this.loadCache.has(id)) continue const dependency = this.graphRows.get(id) - if (dependency !== undefined) await this.arriveGraphRow(dependency, next) + if (dependency !== undefined) await this.arriveGraphRow(dependency, next, visited) + } + for (const packageName of row.inject) { + const dependency = this.graphRows.get(packageName) + if (dependency !== undefined) await this.arriveGraphRow(dependency, [], visited) } await this.arrive(row) } diff --git a/packages/client/modules/tests/loader.client.spec.ts b/packages/client/modules/tests/loader.client.spec.ts index 747067ba37..07e83e306b 100644 --- a/packages/client/modules/tests/loader.client.spec.ts +++ b/packages/client/modules/tests/loader.client.spec.ts @@ -20,7 +20,7 @@ afterEach(() => { }) const row = (id: string, fields: Partial = {}): BootModuleRow => - ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', external: [], ...fields }) + ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', inject: [], external: [], ...fields }) interface Bench { loader: ClientModuleLoader @@ -147,6 +147,22 @@ describe('lazy CJS arrival', () => { expect(exports.react.marker).toBe('react') }) + it('registers injected package factories before materializing a consumer', async () => { + const b = bench([ + row('consumer', { inject: ['provider'] }), + row('provider', { inject: ['consumer'] }), + ], { + consumer: req => ({ provider: req('provider/client') }), + provider: () => ({ marker: 'provider' }), + }) + const exports = await b.loader.import('consumer', '', {}) as { provider: { marker: string } } + expect(b.fetched).toEqual([ + '/plugins/provider/client.js?rev=0', + '/plugins/consumer/client.js?rev=0', + ]) + expect(exports.provider.marker).toBe('provider') + }) + it('concurrent callers share one in-flight arrival and materialize once', async () => { const ran: string[] = [] const url = '/plugins/a/client.js?rev=0' @@ -306,13 +322,13 @@ describe('boot manifest wire', () => { const manifest = parseBootManifest({ rev: 'graph', entries: [ - { id: 'a', url: '/plugins/a/client.js', rev: '1' }, + { id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'] }, { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] }, ], }) expect(manifest.modules).toEqual([ - { id: 'a', url: '/plugins/a/client.js', rev: '1', external: [] }, - { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] }, + { id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'], external: [] }, + { id: 'b', url: '/plugins/b/client.js', rev: '2', inject: [], external: ['react'] }, ]) }) From ce1247d9531450abdf5634117440b092fb7113d5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:39:10 +0800 Subject: [PATCH 027/138] docs(client): sync injected module graph contract --- docs/subsystems/client-modules.i18n.yaml | 4 ++-- docs/subsystems/client-modules.md | 11 +++++------ docs/subsystems/client-modules.zh.md | 11 +++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 9e4e1a9dd7..de013f0be4 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.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/client-modules.md -client-modules.md: ae767c6a098cb1b7188c06e61efd2798eb93ff66 -client-modules.zh.md: 710453e3e10c298ed8c2b87bbab6e8e9eb154a87 +client-modules.md: 84d673669825495b7e22933da238daba24ec7b13 +client-modules.zh.md: 7cc353007732188f06738a745b750161a11366a5 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index ae767c6a09..84d6736698 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -14,11 +14,10 @@ The graph is the wire single source between the Node and browser halves: the hos /** * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. - * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's `dsh.client` - * declaration and reach fibers through entry creation). `external` carries - * module-graph edges: unlike `inject`, they constrain code arrival because - * `require` is synchronous (see {@link WebBootGraph.entries}). + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). */ interface WebBootEntry { /** Entry name == package name. */ @@ -27,7 +26,7 @@ interface WebBootEntry { url: string /** Bundle content hash (cache-busting consistency anchor). */ rev: string - /** Package-name dependency edges, informational (preflight display / HMR diffing). */ + /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ immediately?: boolean diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index 710453e3e1..7cc3530077 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -14,11 +14,10 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client /** * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. - * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's `dsh.client` - * declaration and reach fibers through entry creation). `external` carries - * module-graph edges: unlike `inject`, they constrain code arrival because - * `require` is synchronous (see {@link WebBootGraph.entries}). + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). */ interface WebBootEntry { /** Entry name == package name. */ @@ -27,7 +26,7 @@ interface WebBootEntry { url: string /** Bundle content hash (cache-busting consistency anchor). */ rev: string - /** Package-name dependency edges, informational (preflight display / HMR diffing). */ + /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ immediately?: boolean From 92cac5d291a160fff39edd67df8dad99afa4d1c2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:58:48 +0800 Subject: [PATCH 028/138] fix(webworker): close Node compatibility gaps --- .../builtin_modules/implemented/fs-watch.ts | 30 ++++-- .../node/builtin_modules/implemented/fs.ts | 47 +++++---- .../builtin_modules/implemented/stream.ts | 2 +- .../src/shell/process/landlock.ts | 9 +- .../webworker-runtime/src/storage/memory.ts | 8 ++ .../tests/node/child-process.spec.ts | 4 + .../tests/node/fs-watch-stream.spec.ts | 96 ++++++++++++++++++- .../tests/storage/memory-vfs.spec.ts | 37 +++++++ 8 files changed, 201 insertions(+), 32 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts index 61e3f4419c..a570e54bc2 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts @@ -354,12 +354,20 @@ export function watchAsync( let failure: Error | undefined let closed = false - const settleFailure = (reason: unknown): void => { - if (failure !== undefined || closed) return - const error = reason instanceof Error ? reason : new Error(String(reason)) - failure = error + const stopWatcher = (): void => { + options.signal?.removeEventListener('abort', onAbort) watcher?.close() - for (const pending of waiting.splice(0)) pending.reject(error) + } + const settleFailure = (reason: unknown): void => { + if (closed) return + const error = reason instanceof Error ? reason : new Error(String(reason)) + closed = true + queued.length = 0 + stopWatcher() + const failed = waiting.shift() + if (failed === undefined) failure = error + else failed.reject(error) + for (const pending of waiting.splice(0)) pending.resolve({ done: true, value: undefined }) } const onAbort = (): void => { settleFailure(abortError(options.signal?.reason)) } const start = (): void => { @@ -382,11 +390,11 @@ export function watchAsync( } } const close = (): void => { - if (closed) return + const alreadyClosed = closed closed = true queued.length = 0 - options.signal?.removeEventListener('abort', onAbort) - watcher?.close() + failure = undefined + if (!alreadyClosed) stopWatcher() for (const pending of waiting.splice(0)) pending.resolve({ done: true, value: undefined }) } @@ -396,7 +404,11 @@ export function watchAsync( }, next(): Promise> { start() - if (failure !== undefined) return Promise.reject(failure) + if (failure !== undefined) { + const reason = failure + failure = undefined + return Promise.reject(reason) + } const event = queued.shift() if (event !== undefined) return Promise.resolve({ done: false, value: event }) if (closed) return Promise.resolve({ done: true, value: undefined }) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index e4d7099f3e..b2f4462a50 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -319,14 +319,16 @@ export function openSync(path: PathArg, flags = 'r', mode?: number): number { return fd } +const badFileDescriptor = (syscall: string): never => { + const error = new Error(`EBADF: bad file descriptor, ${syscall}`) as Error & { code: string; syscall: string } + error.code = 'EBADF' + error.syscall = syscall + throw error +} + const fileOf = (fd: number, syscall: string): OpenFile => { const file = openFiles.get(fd) - if (file === undefined) { - const error = new Error(`EBADF: bad file descriptor, ${syscall}`) as Error & { code: string; syscall: string } - error.code = 'EBADF' - error.syscall = syscall - throw error - } + if (file === undefined) return badFileDescriptor(syscall) return file } @@ -558,15 +560,18 @@ export class ReadStream extends Readable { callback(abortError(this.signal.reason)) return } + let fd: number try { - this.fd = openSync(this.path, this.flags) - this.pending = false - this.emit('open', this.fd) - this.emit('ready') - callback() + fd = openSync(this.path, this.flags) } catch (error) { callback(error as Error) + return } + this.fd = fd + this.pending = false + callback() + this.emit('open', fd) + this.emit('ready') } override _read(size: number): void { @@ -648,16 +653,19 @@ export class WriteStream extends Writable { callback(abortError(this.signal.reason)) return } + let fd: number try { - this.fd = openSync(this.path, this.flags, this.mode) - if (this.start !== undefined) fileOf(this.fd, 'write').position = this.start - this.pending = false - this.emit('open', this.fd) - this.emit('ready') - callback() + fd = openSync(this.path, this.flags, this.mode) } catch (error) { callback(error as Error) + return } + this.fd = fd + if (this.start !== undefined) fileOf(fd, 'write').position = this.start + this.pending = false + callback() + this.emit('open', fd) + this.emit('ready') } override _write( @@ -666,9 +674,10 @@ export class WriteStream extends Writable { callback: (error?: Error | null) => void, ): void { try { - if (this.fd === null) throw new Error('EBADF: bad file descriptor, write') + const fd = this.fd + if (fd === null) return badFileDescriptor('write') const data = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk - this.bytesWritten += writeSync(this.fd, data) + this.bytesWritten += writeSync(fd, data) callback() } catch (error) { callback(error as Error) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts index a7fca23540..19c60afa15 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts @@ -46,7 +46,7 @@ if (getDefaultHighWaterMark(false) !== 64 * 1024) setDefaultHighWaterMark(false, const _isArrayBufferView = (value: unknown): value is ArrayBufferView => ArrayBuffer.isView(value) /** Default-import namespace carrying Node's stream class and static helpers. */ -const streamDefault = Object.assign(Stream, { +const streamDefault = Object.assign(StreamBase, { _isArrayBufferView, getDefaultHighWaterMark, isDestroyed, diff --git a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts index 03ff455c64..5d8c8c3ca0 100644 --- a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts +++ b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts @@ -108,13 +108,18 @@ export async function landlockFileSystem( const readWrite = await Promise.all(invocation.readWrite.map(normalizeGrant)) const readable = [...readOnly, ...readWrite] - const readPath = (path: string, syscall: string): string => { + const checkedPath = (path: string, syscall: string): string => { const target = vfsPath(path, cwd) + if (target.startsWith(`${NULL_PATH}/`)) throw filesystemError('ENOTDIR', syscall, path) + return target + } + const readPath = (path: string, syscall: string): string => { + const target = checkedPath(path, syscall) if (!readable.some(root => contains(root, target))) deny(syscall, path) return target } const writePath = (path: string, syscall: string): string => { - const target = vfsPath(path, cwd) + const target = checkedPath(path, syscall) if (!readWrite.some(root => contains(root, target))) deny(syscall, path) return target } diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 7502e90bb8..5a6826f2e3 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -699,6 +699,14 @@ export class MemoryVfs implements Vfs { return } if (!this.directories.has(source)) fail('ENOENT', 'rename', source) + if (this.files.has(destination)) fail('ENOTDIR', 'rename', destination) + if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination) + if (this.directories.has(destination)) { + if (this.readdirSync(destination).length > 0) fail('ENOTEMPTY', 'rename', destination) + this.directories.delete(destination) + this.directoryModes.delete(destination) + this.directoryMtimes.delete(destination) + } const prefix = `${source}${SEP}` const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = [] for (const [candidate, value] of [...this.files]) { diff --git a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts index 3c5304e8df..a1e6fd3e9c 100644 --- a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts @@ -160,6 +160,10 @@ it('enforces every ShellFileSystem operation and virtual device edge', async () await expect(guarded.mkdir('/dev/null', false)).rejects.toMatchObject({ code: 'EEXIST' }) await expect(guarded.remove('/dev/null', { recursive: false, force: false })).rejects.toMatchObject({ code: 'EACCES' }) await expect(guarded.rename('/dev/null', `${WORKSPACE}/null`)).rejects.toMatchObject({ code: 'EACCES' }) + await expect(guarded.stat('/dev/null/child')).rejects.toMatchObject({ code: 'ENOTDIR' }) + await expect(guarded.writeText('/dev/null/child', 'not written')).rejects.toMatchObject({ code: 'ENOTDIR' }) + await expect(guarded.mkdir('/dev/null/child', true)).rejects.toMatchObject({ code: 'ENOTDIR' }) + expect(vfs.existsSync('/dev')).toBe(false) await expect(guarded.readText(`${HOME}/private.txt`)).rejects.toMatchObject({ code: 'EACCES' }) await guarded.mkdir('created', false) diff --git a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts index df8c31e0b5..de84fcce85 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts @@ -273,6 +273,9 @@ describe('file streams', () => { const values: string[] = [] for await (const value of workerStream.Readable.from(['one', 'two'])) values.push(String(value)) expect(values).toEqual(['one', 'two']) + expect(workerStream.default).toBe(workerStream.Stream) + expect(new workerStream.Writable({ write: (_chunk, _encoding, callback) => { callback() } })) + .toBeInstanceOf(workerStream.default) expect(typeof workerStream.pipeline).toBe('function') expect(typeof workerStream.finished).toBe('function') expect(workerStream.getDefaultHighWaterMark(false)).toBe(64 * 1024) @@ -385,6 +388,91 @@ describe('file streams', () => { }) expect(events).toEqual(['error', 'close']) }) + + it('publishes descriptors before open and ready listener exceptions escape', () => { + const readPath = `${VFS_ROOT}/listener-read.txt` + vfs.writeFileSync(readPath, 'content') + const readCallback = vi.fn() + const readFailure = new Error('read open listener failed') + const readReceiver: { + path: string + flags: string + start: number + end: number + signal: undefined + pending: boolean + fd: number | null + emit(event: string): boolean + } = { + path: readPath, + flags: 'r', + start: 0, + end: Number.POSITIVE_INFINITY, + signal: undefined, + pending: true, + fd: null, + emit(event) { + expect(readCallback).toHaveBeenCalledOnce() + if (event === 'open') throw readFailure + return true + }, + } + expect(() => { + workerFs.ReadStream.prototype._construct.call( + readReceiver as unknown as workerFs.ReadStream, + readCallback, + ) + }).toThrow(readFailure) + expect(readReceiver.pending).toBe(false) + expect(readReceiver.fd).not.toBeNull() + workerFs.closeSync(readReceiver.fd as number) + + const writeCallback = vi.fn() + const writeFailure = new Error('write ready listener failed') + const writeReceiver: { + path: string + flags: string + mode: undefined + start: undefined + signal: undefined + pending: boolean + fd: number | null + emit(event: string): boolean + } = { + path: `${VFS_ROOT}/listener-write.txt`, + flags: 'w', + mode: undefined, + start: undefined, + signal: undefined, + pending: true, + fd: null, + emit(event) { + expect(writeCallback).toHaveBeenCalledOnce() + if (event === 'ready') throw writeFailure + return true + }, + } + expect(() => { + workerFs.WriteStream.prototype._construct.call( + writeReceiver as unknown as workerFs.WriteStream, + writeCallback, + ) + }).toThrow(writeFailure) + expect(writeReceiver.pending).toBe(false) + expect(writeReceiver.fd).not.toBeNull() + workerFs.closeSync(writeReceiver.fd as number) + }) + + it('codes a write before descriptor publication as EBADF', () => { + let failure: Error | null | undefined + workerFs.WriteStream.prototype._write.call( + { fd: null } as unknown as workerFs.WriteStream, + Buffer.from('x'), + 'utf8', + (error) => { failure = error }, + ) + expect(failure).toMatchObject({ code: 'EBADF', syscall: 'write' }) + }) }) interface StatTransition { @@ -673,8 +761,12 @@ describe('watchers', () => { const event = iterator.next() vfs.writeFileSync(`${VFS_ROOT}/async.txt`, 'x') await expect(event).resolves.toEqual({ done: false, value: { eventType: 'rename', filename: 'async.txt' } }) + const failed = iterator.next() + const completed = iterator.next() controller.abort() - await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + await expect(failed).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + await expect(completed).resolves.toEqual({ done: true, value: undefined }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) it('rejects the first promise-watch read for a pre-aborted signal', async () => { @@ -683,6 +775,7 @@ describe('watchers', () => { controller.abort(reason) const iterator = workerFsp.watch(VFS_ROOT, { signal: controller.signal })[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR', cause: reason }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) it('lets promise-watch return interrupt a pending next call', async () => { @@ -697,6 +790,7 @@ describe('watchers', () => { it('propagates promise-watch startup and throw failures', async () => { const missing = workerFsp.watch(`${VFS_ROOT}/missing`)[Symbol.asyncIterator]() await expect(missing.next()).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(missing.next()).resolves.toEqual({ done: true, value: undefined }) const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() const reason = { reason: 'caller stopped iteration' } diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index 069da0ca2a..ac994e84aa 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -220,6 +220,43 @@ describe('mutation publication', () => { }) }) +describe('directory rename', () => { + it('rejects file, non-empty directory, and missing-parent destinations before mutation', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/source/nested/file', 'source') + vfs.seed('/dsh/file', 'destination') + vfs.seed('/dsh/non-empty/child', 'destination') + const mutations: VfsMutation[] = [] + vfs.subscribe((mutation) => { mutations.push(mutation) }) + + expect(() => { vfs.renameSync('/dsh/source', '/dsh/file') }) + .toThrow(expect.objectContaining({ code: 'ENOTDIR' })) + expect(() => { vfs.renameSync('/dsh/source', '/dsh/non-empty') }) + .toThrow(expect.objectContaining({ code: 'ENOTEMPTY' })) + expect(() => { vfs.renameSync('/dsh/source', '/missing/destination') }) + .toThrow(expect.objectContaining({ code: 'ENOENT' })) + + expect(vfs.readFileSync('/dsh/source/nested/file', 'utf8')).toBe('source') + expect(vfs.readFileSync('/dsh/file', 'utf8')).toBe('destination') + expect(vfs.readFileSync('/dsh/non-empty/child', 'utf8')).toBe('destination') + expect(mutations).toEqual([]) + }) + + it('replaces an empty directory with the source subtree', () => { + const vfs = new MemoryVfs() + vfs.seedDirectory('/dsh/source/nested', { mode: 0o700 }) + vfs.seed('/dsh/source/nested/file', 'source') + vfs.seedDirectory('/dsh/destination', { mode: 0o711 }) + + vfs.renameSync('/dsh/source', '/dsh/destination') + + expect(vfs.existsSync('/dsh/source')).toBe(false) + expect(vfs.readFileSync('/dsh/destination/nested/file', 'utf8')).toBe('source') + expect((vfs.statSync('/dsh/destination') as VfsStats).mode & 0o777).toBe(0o755) + expect((vfs.statSync('/dsh/destination/nested') as VfsStats).mode & 0o777).toBe(0o700) + }) +}) + describe('hard links', () => { it('shares identity, bytes, and mode until one name is removed', () => { const vfs = new MemoryVfs() From ab0f7937caa4f1006fbd1efce6f49e81c86650e8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:33:51 +0800 Subject: [PATCH 029/138] perf(webworker): index VFS hard links --- .../webworker-runtime/src/storage/memory.ts | 103 ++++++++++++++---- .../tests/storage/memory-vfs.spec.ts | 62 ++++++++++- 2 files changed, 140 insertions(+), 25 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 5a6826f2e3..24d12a688e 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -22,6 +22,8 @@ interface FileNode { mode: number /** Stable identity shared by hard links and retained by open descriptors. */ identity?: bigint + /** One path normally, a Set only for hard links, or undefined after the final unlink. */ + paths: string | Set | undefined } /** Creation default for files, Node's `0o666` under the classic `022` umask. */ @@ -309,7 +311,7 @@ export class MemoryVfs implements Vfs { : fail('ENOENT', 'stat', target) const identity = node === undefined ? this.identityOf(target) : this.identityOfFile(node) return options?.bigint === true - ? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.pathsOf(node).length) + ? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.fileLinkCount(node)) : statsOf(size, mtimeMs, directory, identity, mode) } @@ -335,23 +337,70 @@ export class MemoryVfs implements Vfs { return node.identity } - /** @returns Every currently linked path for one file node. */ - private pathsOf(node: FileNode): string[] { - const paths: string[] = [] - for (const [path, candidate] of this.files) { - if (candidate === node) paths.push(path) + /** @returns The number of names currently linked to one file node. */ + private fileLinkCount(node: FileNode): number { + return typeof node.paths === 'string' ? 1 : node.paths?.size ?? 0 + } + + /** Add one map name, promoting the rare hard-link case to a Set. */ + private addFilePath(node: FileNode, path: string): void { + if (node.paths === undefined) { + node.paths = path + } else if (typeof node.paths === 'string') { + node.paths = new Set([node.paths, path]) + } else { + node.paths.add(path) } - return paths + } + + /** Remove one map name, collapsing a remaining single link back to a string. */ + private removeFilePath(node: FileNode, path: string): void { + if (typeof node.paths === 'string') { + node.paths = undefined + return + } + if (node.paths === undefined) return + node.paths.delete(path) + if (node.paths.size === 1) { + const [remaining] = node.paths + node.paths = remaining + } + } + + /** Set one file-map entry while maintaining both nodes' reverse path indexes. */ + private setFile(path: string, node: FileNode): void { + const previous = this.files.get(path) + if (previous === node) return + if (previous !== undefined) this.removeFilePath(previous, path) + this.files.set(path, node) + this.addFilePath(node, path) + } + + /** Delete one file-map entry while retaining an unlinked node held by a descriptor. */ + private deleteFile(path: string): FileNode | undefined { + const node = this.files.get(path) + if (node === undefined) return undefined + this.files.delete(path) + this.removeFilePath(node, path) + return node + } + + /** Publish one linked name after a content or metadata write. */ + private publishFilePath(node: FileNode, path: string, appendedFrom?: number): void { + this.publish({ + kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false, + ...appendedFrom === undefined ? {} : { appendedFrom }, + }) } /** Publish a content or metadata write for every hard link to one node. */ private publishFile(node: FileNode, appendedFrom?: number): void { - for (const path of this.pathsOf(node)) { - this.publish({ - kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false, - ...appendedFrom === undefined ? {} : { appendedFrom }, - }) + if (typeof node.paths === 'string') { + this.publishFilePath(node, node.paths, appendedFrom) + return } + if (node.paths === undefined) return + for (const path of node.paths) this.publishFilePath(node, path, appendedFrom) } /** Replace bytes on one file identity and notify all linked paths. */ @@ -517,8 +566,8 @@ export class MemoryVfs implements Vfs { this.replaceFile(previous, bytes) return } - const node: FileNode = { bytes, mtimeMs: this.touch(target), mode } - this.files.set(target, node) + const node: FileNode = { bytes, mtimeMs: this.touch(target), mode, paths: undefined } + this.setFile(target, node) this.touchDirectory(dirname(target)) this.publish({ kind: 'write', path: target, bytes, mode, entryChanged: true }) } @@ -688,8 +737,9 @@ export class MemoryVfs implements Vfs { if (node !== undefined) { if (this.directories.has(destination)) fail('EISDIR', 'rename', destination) if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination) - this.files.delete(source) - this.files.set(destination, node) + if (this.files.get(destination) === node) return + this.deleteFile(source) + this.setFile(destination, node) this.forgetIdentity(source) this.forgetIdentity(destination) this.touchDirectory(dirname(source)) @@ -711,9 +761,9 @@ export class MemoryVfs implements Vfs { const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = [] for (const [candidate, value] of [...this.files]) { if (!candidate.startsWith(prefix)) continue - this.files.delete(candidate) + this.deleteFile(candidate) const target = join(destination, candidate.slice(prefix.length)) - this.files.set(target, value) + this.setFile(target, value) movedFiles.push({ path: target, bytes: value.bytes, mode: value.mode }) } const movedDirectories: Array<{ path: string; mode: number }> = [] @@ -760,7 +810,7 @@ export class MemoryVfs implements Vfs { if (node === undefined) fail('ENOENT', 'link', source) if (this.files.has(target) || this.directories.has(target)) fail('EEXIST', 'link', target) if (!this.directories.has(dirname(target))) fail('ENOENT', 'link', target) - this.files.set(target, node) + this.setFile(target, node) this.touchDirectory(dirname(target)) this.publish({ kind: 'write', path: target, bytes: node.bytes, mode: node.mode, entryChanged: true }) } @@ -787,7 +837,11 @@ export class MemoryVfs implements Vfs { const node = this.files.get(target) if (node !== undefined) { node.mode = mode & 0o777 - for (const path of this.pathsOf(node)) this.publish({ kind: 'chmod', path, mode: node.mode }) + if (typeof node.paths === 'string') { + this.publish({ kind: 'chmod', path: node.paths, mode: node.mode }) + } else if (node.paths !== undefined) { + for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode }) + } return } if (this.directories.has(target)) { @@ -805,7 +859,7 @@ export class MemoryVfs implements Vfs { */ unlinkSync(path: string): void { const target = this.key(path) - if (!this.files.delete(target)) fail('ENOENT', 'unlink', target) + if (this.deleteFile(target) === undefined) fail('ENOENT', 'unlink', target) this.forgetIdentity(target) this.touchDirectory(dirname(target)) this.publish({ kind: 'remove', path: target }) @@ -818,7 +872,7 @@ export class MemoryVfs implements Vfs { */ rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void { const target = this.key(path) - if (this.files.delete(target)) { + if (this.deleteFile(target) !== undefined) { this.forgetIdentity(target) this.touchDirectory(dirname(target)) this.publish({ kind: 'remove', path: target }) @@ -827,7 +881,7 @@ export class MemoryVfs implements Vfs { if (this.directories.has(target)) { if (options?.recursive !== true) fail('ERR_FS_EISDIR', 'rm', target) const prefix = `${target}${SEP}` - for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.files.delete(candidate) + for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.deleteFile(candidate) for (const candidate of [...this.directories]) { if (!candidate.startsWith(prefix)) continue this.directories.delete(candidate) @@ -866,10 +920,11 @@ export class MemoryVfs implements Vfs { seed(path: string, data: string | Uint8Array, options: VfsSeedOptions = {}): void { const target = this.key(path) this.seedDirectory(dirname(target)) - this.files.set(target, { + this.setFile(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: options.mtimeMs ?? this.touch(target), mode: (options.mode ?? DEFAULT_FILE_MODE) & 0o777, + paths: undefined, }) this.touchDirectory(dirname(target)) } diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index ac994e84aa..b80987cd4c 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -17,6 +17,9 @@ import type { VfsBigIntStats, VfsMutation, VfsMutationSink, VfsStats } from '../ const identity = (vfs: MemoryVfs, path: string): bigint => (vfs.statSync(path, { bigint: true }) as VfsBigIntStats).ino +const linkCount = (vfs: MemoryVfs, path: string): bigint => + (vfs.statSync(path, { bigint: true }) as VfsBigIntStats).nlink + const modified = (vfs: MemoryVfs, path: string): number => (vfs.statSync(path) as VfsStats).mtimeMs afterEach(() => { vi.restoreAllMocks() }) @@ -262,20 +265,77 @@ describe('hard links', () => { const vfs = new MemoryVfs() vfs.seed('/dsh/session.jsonl', 'committed\n') vfs.linkSync('/dsh/session.jsonl', '/dsh/session-latest.jsonl') + vfs.linkSync('/dsh/session-latest.jsonl', '/dsh/session-archive.jsonl') expect(identity(vfs, '/dsh/session-latest.jsonl')).toBe(identity(vfs, '/dsh/session.jsonl')) + expect(linkCount(vfs, '/dsh/session.jsonl')).toBe(3n) expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\n') const changedPaths: string[] = [] vfs.subscribe((mutation) => { changedPaths.push(mutation.path) }) vfs.appendFileSync('/dsh/session.jsonl', 'appended\n') - expect(changedPaths).toEqual(['/dsh/session.jsonl', '/dsh/session-latest.jsonl']) + expect(changedPaths).toEqual([ + '/dsh/session.jsonl', + '/dsh/session-latest.jsonl', + '/dsh/session-archive.jsonl', + ]) expect(vfs.readFileSync('/dsh/session.jsonl', 'utf8')).toBe('committed\nappended\n') expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\nappended\n') vfs.chmodSync('/dsh/session-latest.jsonl', 0o600) expect((vfs.statSync('/dsh/session.jsonl') as VfsStats).mode & 0o777).toBe(0o600) vfs.unlinkSync('/dsh/session-latest.jsonl') + expect(linkCount(vfs, '/dsh/session.jsonl')).toBe(2n) + vfs.unlinkSync('/dsh/session-archive.jsonl') + expect(linkCount(vfs, '/dsh/session.jsonl')).toBe(1n) expect(vfs.readFileSync('/dsh/session.jsonl', 'utf8')).toBe('committed\nappended\n') }) + it('treats rename between names of the same node as a no-op', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/source', 'value') + vfs.linkSync('/dsh/source', '/dsh/alias') + const mutations: VfsMutation[] = [] + vfs.subscribe((mutation) => { mutations.push(mutation) }) + + vfs.renameSync('/dsh/source', '/dsh/alias') + + expect(vfs.readFileSync('/dsh/source', 'utf8')).toBe('value') + expect(vfs.readFileSync('/dsh/alias', 'utf8')).toBe('value') + expect(linkCount(vfs, '/dsh/source')).toBe(2n) + expect(mutations).toEqual([]) + }) + + it('retargets linked names through file replacement and directory moves', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/replacement', 'replacement') + vfs.seed('/dsh/target', 'old') + vfs.linkSync('/dsh/target', '/dsh/target-alias') + const replaced = vfs.openFileSync('/dsh/target', 'r+') + vfs.renameSync('/dsh/replacement', '/dsh/target') + const mutations: VfsMutation[] = [] + vfs.subscribe((mutation) => { mutations.push(mutation) }) + + replaced.write(0, new TextEncoder().encode('changed')) + expect(mutations.map(mutation => mutation.path)).toEqual(['/dsh/target-alias']) + expect(vfs.readFileSync('/dsh/target', 'utf8')).toBe('replacement') + expect(vfs.readFileSync('/dsh/target-alias', 'utf8')).toBe('changed') + expect(linkCount(vfs, '/dsh/target-alias')).toBe(1n) + + vfs.seed('/dsh/tree/file', 'tree') + vfs.linkSync('/dsh/tree/file', '/dsh/outside') + const moved = vfs.openFileSync('/dsh/tree/file', 'r+') + vfs.renameSync('/dsh/tree', '/dsh/moved') + mutations.length = 0 + moved.write(0, new TextEncoder().encode('moved')) + expect(mutations.map(mutation => mutation.path)).toEqual(['/dsh/outside', '/dsh/moved/file']) + expect(linkCount(vfs, '/dsh/moved/file')).toBe(2n) + + vfs.rmSync('/dsh/moved', { recursive: true }) + mutations.length = 0 + moved.write(0, new TextEncoder().encode('kept!')) + expect(mutations.map(mutation => mutation.path)).toEqual(['/dsh/outside']) + expect(vfs.readFileSync('/dsh/outside', 'utf8')).toBe('kept!') + expect(linkCount(vfs, '/dsh/outside')).toBe(1n) + }) + it('rejects renaming a file over an existing directory', () => { const vfs = new MemoryVfs() vfs.seed('/dsh/file', 'value') From e06625d202ba53836a16865e0f779a44a85ec167 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:43:15 +0800 Subject: [PATCH 030/138] fix(web): switch running drafts to Send --- ...08-20-running-draft-primary-send.i18n.yaml | 6 +++ .../2026-08-20-running-draft-primary-send.md | 31 +++++++++++ ...026-08-20-running-draft-primary-send.zh.md | 31 +++++++++++ ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 +- .../2026-08-06-web-queue-steer-all-gesture.md | 2 +- ...26-08-06-web-queue-steer-all-gesture.zh.md | 2 +- apps/web/tests/live-interactions.e2e.ts | 35 ++++++++++--- .../running-draft.expected.md | 28 ++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/skeleton/InputBar.tsx | 8 +-- .../tests/input-bar.client.spec.tsx | 52 +++++++++++++++++-- 13 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md create mode 100644 apps/web/tests/snapshots/live-interactions/running-draft.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.i18n.yaml new file mode 100644 index 0000000000..5a1d90d7f1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.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-20-running-draft-primary-send.md +2026-08-20-running-draft-primary-send.md: 79c8c3a74fc49a325b229324b0d96b2387308d3d +2026-08-20-running-draft-primary-send.zh.md: 180352810f57ecc04882322318f9708cf247a09c diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md new file mode 100644 index 0000000000..79c8c3a74f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md @@ -0,0 +1,31 @@ +# Agent Note: Running drafts take the primary Send action + +Status: implemented + +English | [中文](2026-08-20-running-draft-primary-send.zh.md) + +## Problem + +The ordinary Web composer remains editable while a Turn is running, and keyboard submission can queue or steer its draft. Its single primary pointer control nevertheless stayed on Stop for the entire Turn. A pointer user who entered a follow-up and activated that control stopped the current Turn instead of submitting the visible draft, so the control contradicted the composer's editable state and the user's current content. + +## Decision + +`InputBar` chooses the ordinary session's primary action from the running state, draft content, and owner block. An empty running composer shows Stop and routes it through the existing session cancellation callback. Non-whitespace text or at least one attachment changes that same control to Send; the click uses the existing Queue submission path. An owner-blocked running composer keeps Stop even when a retained draft exists, because the block disables both editing and submission. Clearing the draft or completing a successful submission restores Stop while the Turn remains active. Idle sessions continue to show Send, disabled while the draft is empty or submission is unavailable. + +The pointer action does not inherit the `ui-conversation.busyEnter` preference. That preference continues to choose Queue or Steer only for the two keyboard gestures. Continuable subagents retain independent Send and Stop controls, and one-shot subagents retain their read-only behavior. + +## Verification + +The `InputBar` component test covers empty, text, cleared, submitted, attachment-only, and owner-blocked running drafts, including Queue submission while the keyboard preference selects Steer. The keyless assembled Web scenario parks a real composed Turn in the replay adapter, captures the running draft with Send, clicks it through the Host Queue path, observes Stop return after the draft clears, removes the queued row, and then cancels the Turn. + +## Alternatives considered + +**Keep Stop for the whole running Turn.** This preserves immediate cancellation but leaves the visible editable draft without a pointer submission action and makes the primary control act against the content beside it. + +**Render Send and Stop simultaneously for every running session.** Continuable subagents need two independent operations because their cancellation route differs from continuation delivery. Ordinary sessions have one established primary seat; adding a permanent second control would spend more space and create a different hierarchy when the draft itself already identifies the immediate action. + +**Apply the busy-Enter preference to pointer Send.** A button labeled Send would silently change between Queue and Steer according to a keyboard preference. Keeping pointer submission on Queue preserves the existing explicit distinction and avoids an invisible mode on the button. + +## Consequences + +Pointer users can submit a follow-up without waiting for the active Turn or using a keyboard shortcut. An actionable draft occupies the single primary seat, so Stop returns after the draft is cleared or accepted rather than remaining simultaneously visible; an owner block returns that seat to Stop because the retained draft cannot be edited or submitted. Keyboard delivery selection, cancellation transport, and subagent controls are unchanged. Issue #2850 records the user-facing defect and acceptance boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md new file mode 100644 index 0000000000..180352810f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 运行中草稿取得主 Send 操作 + +Status: implemented + +[English](2026-08-20-running-draft-primary-send.md) | 中文 + +## 问题 + +普通 Web composer 在 Turn 运行期间仍可编辑,键盘提交也能把草稿送入 Queue 或 Steer。然而,其唯一的主指针控件会在整个 Turn 中一直保持 Stop。指针用户输入后续消息并激活该控件时,会停止当前 Turn,而不是提交眼前的草稿;该控件因而与 composer 的可编辑状态和用户当前内容相冲突。 + +## 决策 + +`InputBar` 根据运行状态、草稿内容和 owner block 选择普通会话的主操作。运行中的 composer 为空时显示 Stop,并通过既有会话取消回调执行。存在非空白文字或至少一个附件时,同一控件切换为 Send,点击后使用既有 Queue 提交路径。owner block 会禁用编辑与提交,因此即使保留了草稿,运行中的 composer 也保持 Stop。清空草稿或成功提交后,只要 Turn 仍在运行,就会恢复 Stop。空闲会话仍显示 Send;草稿为空或无法提交时,该按钮保持禁用。 + +指针操作不继承 `ui-conversation.busyEnter` 偏好。该偏好仍然只为两个键盘手势选择 Queue 或 Steer。可继续 subagent 保留相互独立的 Send 与 Stop 控件,one-shot subagent 保持只读行为。 + +## 验证 + +`InputBar` 组件测试覆盖运行中草稿的空白、文字、清空、提交成功、仅附件和 owner-blocked 状态,并证明键盘偏好选择 Steer 时,按钮提交仍使用 Queue。无密钥的组装 Web 场景通过 replay 适配器停住真实组合出的 Turn,捕获显示 Send 的运行中草稿,经 Host Queue 路径点击提交,在草稿清空后观察 Stop 恢复,移除 Queue 行,再取消该 Turn。 + +## 备选方案 + +**在整个运行中 Turn 保持 Stop。** 这样可以始终立即取消,但可见的可编辑草稿没有指针提交操作,主控件也会执行与相邻内容相反的动作。 + +**为每个运行中会话同时渲染 Send 与 Stop。** 可继续 subagent 需要两个独立操作,因为其取消路由与继续投递不同。普通会话已有单一主操作位置;永久增加第二个控件会占用更多空间,并在草稿本身已经指明当前操作时引入另一套层级。 + +**让指针 Send 采用 busy-Enter 偏好。** 标记为 Send 的按钮会随键盘偏好在 Queue 与 Steer 之间静默变化。保持指针提交始终使用 Queue,可以保留既有显式区分,避免按钮携带不可见模式。 + +## 影响 + +指针用户无需等待当前 Turn 结束或使用键盘快捷键,即可提交后续消息。可操作草稿会占用唯一的主操作位置,因此 Stop 会在草稿清空或被接纳后恢复,而不是同时显示;owner block 会让该位置恢复 Stop,因为保留的草稿无法编辑或提交。键盘投递选择、取消传输和 subagent 控件均不变。Issue #2850 记录用户可见缺陷与验收边界。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml index b47b635768..e7612e322b 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.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-queue-steer-all-gesture.md -2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76 -2026-08-06-web-queue-steer-all-gesture.zh.md: 1f65933ac430ca22ac8d6c78471a3bd08b421ad6 +2026-08-06-web-queue-steer-all-gesture.md: c51e837d37b61a73f9602445daea9fbdfc77a299 +2026-08-06-web-queue-steer-all-gesture.zh.md: b0009a7a51ebe89f61d1a4b995b1138a7247d266 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md index e546f68647..c51e837d37 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -30,4 +30,4 @@ The per-row 插话发送 action and its strict-steer boundary are owned by [Stee - **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. - **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. - **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. -- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly. +- **A send-button tooltip.** Rejected: the primary button is Stop in the empty-draft running window, which is also the only window where the whole-queue gesture is available. The placeholder occupies that exact window and can describe the keyboard action directly. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md index 1f65933ac4..b0009a7a51 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -30,4 +30,4 @@ Status: implemented - **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 - **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 - **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 -- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 +- **发送按钮 tooltip。** 已拒绝:主按钮在空草稿的运行窗口内是 Stop,而这也正是整队列手势唯一可用的窗口。placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 85a7a57e62..0586a900c2 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -1,6 +1,7 @@ -// Web e2e scenarios: live-turn interactions — cancellation, error surfacing, -// transient-retry recovery, and retry exhaustion, all through the real -// composition and wire. The model adapter is dsh-llm-replay with override +// Web e2e scenarios: live-turn interactions — running-draft submission, +// cancellation, error surfacing, transient-retry recovery, and retry +// exhaustion, all through the real composition and wire. The model adapter is +// dsh-llm-replay with override // sidecars: `hang` (+ a readyFile marker) makes mid-stream cancel // deterministic by construction, `throw` entries express provider failures by // stable code, and `{ patches }` augmentation injects transient throws before @@ -29,11 +30,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// One golden pins the stable mid-turn loading state; the other four capture -// what the user is left looking at after cancel, after a non-retryable failure, -// after retry recovery, and after retry exhaustion. +// One golden pins the empty mid-turn loading state, one pins the sendable draft +// state, and the other four capture what remains after cancel, after a +// non-retryable failure, after retry recovery, and after retry exhaustion. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md') const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md') +const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md') const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md') @@ -44,6 +46,7 @@ const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-se // patch. Kept deliberately tool-free so the derived script is exactly one // model call. const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.' +const RUNNING_DRAFT = 'Queue this follow-up while the current turn is running.' /** turn/end reasons observed, in order. */ function turnEndReasons(events: SessionEvent[]): string[] { @@ -147,6 +150,22 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { ).toBe(true) const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE) + + const input = page.locator('textarea').first() + await input.fill(RUNNING_DRAFT) + const send = page.getByRole('button', { name: 'Send message', exact: true }) + await send.waitFor({ timeout: 10_000 }) + expect(await page.getByRole('button', { name: 'Stop generating', exact: true }).count()).toBe(0) + const runningDraftSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(RUNNING_DRAFT_EXPECTED, runningDraftSnapshot, MODE) + await send.click() + await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('') + const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: RUNNING_DRAFT }) + await queuedRow.waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor({ timeout: 10_000 }) + await queuedRow.getByRole('button', { name: 'Remove queued message' }).click() + await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('button', { name: 'Stop generating' }).click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') @@ -281,8 +300,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md', - 'retry-exhausted.expected.md', + 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'running-draft.expected.md', + 'error-auth.expected.md', 'retry.expected.md', 'retry-exhausted.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/live-interactions/running-draft.expected.md b/apps/web/tests/snapshots/live-interactions/running-draft.expected.md new file mode 100644 index 0000000000..d4e15c0498 --- /dev/null +++ b/apps/web/tests/snapshots/live-interactions/running-draft.expected.md @@ -0,0 +1,28 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: partial +- status: Deep diving... +- textbox "Message the agent": Queue this follow-up while the current turn is running. +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a6102ddac0..85e96fbdca 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 4c9665b680fe1922770403d88a04dffc755481ad -README.zh.md: 8bf3db2cb1401427f29016f8dbddcd9d27ec9635 +README.md: 08d1408bea404a80da491078aa013989835fccaf +README.zh.md: 874aea8865e6f74a64da2c249087bdad032d98d5 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4c9665b680..08d1408bea 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -20,6 +20,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +An ordinary running composer keeps Stop as its primary pointer action while its draft is empty or an owner block makes input unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. Keyboard Queue/Steer selection remains governed by the busy-Enter setting, while continuable subagents keep independent Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). + ## Temporary composer entries `conversation.composer` is a generic chain. Its complete owner currency is: diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8bf3db2cb1..874aea8865 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -20,6 +20,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inert,Workspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +普通 composer 运行期间,草稿为空或 owner block 使输入不可用时,主指针操作保持为 Stop。可提交文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。键盘 Queue/Steer 选择仍由繁忙态 Enter 设置决定,可继续 subagent 则保留相互独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 + ## 临时 composer entry `conversation.composer` 是通用 chain,其完整 owner currency 为: diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 8bedb2d260..0a137c8010 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -554,10 +554,10 @@ export function InputBar({ if (el !== null) toggleCommandMenu?.(selectionOf(el)) } - // Ordinary sessions retain their primary Send/Stop toggle. A continuable - // child keeps Send as the primary action and exposes Stop independently so - // pointer users can queue follow-ups while its current turn is running. - const primaryStops = running && subagent === null + // An ordinary running session keeps Stop while the composer is empty or + // owner-blocked; an actionable draft gets the existing Queue action. A + // continuable child keeps Send primary and exposes Stop independently. + const primaryStops = running && subagent === null && (empty || blocked !== undefined) const interruptible = running && continuable const primaryLabel = primaryStops ? t('input.stop') : t('input.send') const onPrimary = (): void => { diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f36b1aa1c4..17064a97a5 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -64,6 +64,7 @@ interface BenchOptions { subagent?: Exclude disabled?: boolean inert?: boolean + blocked?: { readonly reason: string } workspacePickerOpen?: boolean onRequestWorkspace?: () => void promptError?: SessionSnapshot['promptError'] @@ -186,6 +187,7 @@ function bench(over?: BenchOptions) { renderSlot, variant: over?.variant ?? 'composer', ...(over?.inert === true ? { disabled: true } : {}), + ...(over?.blocked !== undefined ? { blocked: over.blocked } : {}), ...(over?.workspacePickerOpen !== undefined ? { workspacePickerOpen: over.workspacePickerOpen } : {}), ...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}), ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -196,7 +198,9 @@ function bench(over?: BenchOptions) { } const view = render() const textarea = view.container.querySelector('textarea')! + const sendableDraft = (over?.draft?.trim() ?? '') !== '' || (over?.attachments?.length ?? 0) > 0 const primaryStops = over?.running === true && over.subagent === undefined + && (!sendableDraft || over.blocked !== undefined) const button = view.container.querySelector( `button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`, )! @@ -606,15 +610,53 @@ describe('Enter semantics', () => { }) describe('running and lock semantics', () => { - it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => { - const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' }) + it('running switches the primary between Stop and Queue Send with the draft', async () => { + const { textarea, button, stop, sink } = bench({ running: true, busyEnter: 'steer' }) expect(textarea.disabled).toBe(false) - fireEvent.change(textarea, { target: { value: '排队消息2' } }) - fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal)) expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) + + fireEvent.change(textarea, { target: { value: '排队消息' } }) + expect(button.getAttribute('aria-label')).toBe('发送消息') + fireEvent.change(textarea, { target: { value: ' ' } }) + expect(button.getAttribute('aria-label')).toBe('停止生成') + fireEvent.change(textarea, { target: { value: '排队消息2' } }) + expect(button.getAttribute('aria-label')).toBe('发送消息') + fireEvent.click(button) + expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal)) + await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') }) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('running treats an attachment-only draft as Send', async () => { + const attachment = { + kind: 'image' as const, + id: 'draft-1' as DraftAttachmentId, + file: new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }), + previewUrl: 'blob:pixel', + } + const { button, sink } = bench({ running: true, attachments: [attachment] }) + expect(button.getAttribute('aria-label')).toBe('发送消息') + fireEvent.click(button) + expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) + await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') }) + }) + + it('running blocked composer keeps Stop with a retained draft', () => { + const { button, sink, stop, textarea } = bench({ + running: true, + draft: '保留的草稿', + blocked: { reason: '请选择可用模型' }, + placeholder: '请选择可用模型', + }) + expect(textarea.disabled).toBe(true) + expect(textarea.placeholder).toBe('请选择可用模型') + expect(button.getAttribute('aria-label')).toBe('停止生成') + expect(button.disabled).toBe(false) + fireEvent.click(button) + expect(stop).toHaveBeenCalledTimes(1) + expect(sink).not.toHaveBeenCalled() }) it('running plain Enter follows the busy-state Steer preference', () => { From 6434b894c22ca6a65d35dfd725ab0be00684013b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 12:02:38 +0800 Subject: [PATCH 031/138] chore(attachment): start issue-2885 image codec work From bd4e4173e71cdd90deb104788addc3d1b54f5228 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 14:49:11 +0800 Subject: [PATCH 032/138] feat(attachment): expose model-readable image paths --- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 6 +- ...08-20-unified-image-request-pipeline.zh.md | 6 +- ...08-21-model-readable-image-paths.i18n.yaml | 6 + .../2026-08-21-model-readable-image-paths.md | 37 ++++ ...026-08-21-model-readable-image-paths.zh.md | 37 ++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 21 ++- docs/subsystems/attachment.zh.md | 21 ++- examples/acp-agent/tests/acp.snapshot.ts | 40 ++-- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 4 +- .../attachment/attachment-local/README.zh.md | 4 +- .../attachment/attachment-local/src/index.ts | 22 ++- .../attachment/attachment-local/src/store.ts | 19 +- .../attachment-local/tests/index.spec.ts | 30 ++- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 6 +- packages/attachment/attachment/README.zh.md | 6 +- packages/attachment/attachment/src/index.ts | 12 ++ packages/attachment/attachment/src/types.ts | 8 + .../attachment/attachment/tests/index.spec.ts | 6 + .../extensions/tool-cordis/src/api-catalog.ts | 12 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/README.zh.md | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 16 +- packages/llm/llm-deepseek/src/index.ts | 17 +- packages/llm/llm-deepseek/src/serialize.ts | 11 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 17 +- .../llm-deepseek/tests/dynamic-config.spec.ts | 2 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 51 +++++- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/context.ts | 6 +- packages/llm/llm-pi-ai/tests/context.spec.ts | 52 +++++- packages/llm/llm-pi-ai/tests/convert.spec.ts | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 6 +- packages/llm/llm/README.zh.md | 6 +- packages/llm/llm/src/content.ts | 94 ++++++---- packages/llm/llm/tests/content.spec.ts | 173 ++++++++++++++++-- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 + 48 files changed, 643 insertions(+), 187 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md create mode 100644 .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 6a379a3532..cca3335220 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: ada15d540539977c631e359ffdc7baa4fa84c78e -2026-08-20-unified-image-request-pipeline.zh.md: 85c9a1f837d82cba2bc62b30402433f50c873cbe +2026-08-20-unified-image-request-pipeline.md: 7af0bea0acb117844b258a0333e97e97facf493c +2026-08-20-unified-image-request-pipeline.zh.md: ecdb71548190ba22573a5e05c3b3fde76c28d511 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index ada15d5405..7af0bea0ac 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -22,15 +22,15 @@ Batch admission prepares and verifies every normalized attachment once before pu ### Deterministic request versions -`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. -Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a deterministic per-image placeholder that retains its identity and current provider access facts, including nested tool-result images, while append-only session history keeps the original references. ### Stable handles -Every retained request image is preceded by its complete attachment id and actual request dimensions. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. +Every retained request image is preceded by its display name or complete attachment id, actual request dimensions, and current provider access facts. The local provider supplies the absolute read-only normalized-object path, normalized dimensions, and media type. The descriptor states that normalization or request projection may have resized or re-encoded the upload, so the model cannot infer original upload properties from either representation. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. The path is derived from the logged reference and current provider root at request time; it does not enter the durable reference or session log. ### DeepSeek Files lifecycle diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 85c9a1f837..ecdb715481 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -22,15 +22,15 @@ Status: implemented ### 确定性请求版本 -`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成确定性的逐图占位文本,保留自己的身份和当前提供方访问事实,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 ### 稳定句柄 -每张保留请求图片前都有完整附件 ID 和实际请求尺寸。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。 +每张保留请求图片前都有显示名称或完整附件 ID、实际请求尺寸,以及当前提供方访问事实。本地提供方会给出规范化对象的绝对只读路径、规范化尺寸和媒体类型。描述会说明规范化或请求投影可能缩小或重新编码上传图片,因此模型不能从任一版本推断上传图片原本的属性。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。路径在请求时根据已记录引用和当前提供方根目录派生,不进入持久引用或会话日志。 ### DeepSeek Files 生命周期 diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml new file mode 100644 index 0000000000..16831b0ca3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.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-21-model-readable-image-paths.md +2026-08-21-model-readable-image-paths.md: 4c2610ad48e47d2642e32ae53048d0897abd64e9 +2026-08-21-model-readable-image-paths.zh.md: 24335f1da99c382291988d5862bdd52e8d5d961c diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md new file mode 100644 index 0000000000..4c2610ad48 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md @@ -0,0 +1,37 @@ +# Agent Note: Provider-resolved image paths in model requests + +Status: implemented + +English | [中文](2026-08-21-model-readable-image-paths.zh.md) + +## Problem + +An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable models receive its request preview, but the prior descriptor gives them no filesystem location for later image operations. Agents consequently search the workspace and temporary directories or ask the user where the file is. The request preview and stored normalized attachment may both differ from the upload, so their dimensions, format, and byte size cannot establish the upload's original properties. + +## Decision + +`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute immutable-object path from the resolved `DSH_HOME`, attachment storage version, and validated digest. A request version carries these facts transiently for serialization. A provider without model-readable local storage returns no access facts. + +The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the provider-resolved normalized path when available. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. + +Request-size offload requires a per-image placeholder function; the previous shared placeholder constant and its byte-bound wrapper had no remaining production caller and are removed. DeepSeek and pi-ai replace each omitted occurrence with its own attachment identity and current access facts without reading or transforming the omitted object. Offload selection, byte accounting, and quantized prefix behavior remain unchanged. + +Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Access resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. + +Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the provider mounted for the current process. Restoring the same session with a different `DSH_HOME` therefore produces the path that is valid on that host. The attachment object remains immutable; model instructions require a writable copy for modifications. + +## Alternatives considered + +**Persist the absolute path in `ImageAttachmentRef`.** A durable host path becomes stale after moving a session, changing `DSH_HOME`, or mounting another provider. Resolving it at request time preserves portable history. + +**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns this fact. + +**Add a dedicated crop or recovery tool.** Standard filesystem and image tools can operate after copying the normalized object. A new tool adds a model schema and access-policy surface without being necessary for path discovery. + +## Verification + +Package tests cover provider access defaults, local digest-to-path resolution, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. + +## Consequences + +The selected model provider receives a host path that was previously local-only. This disclosure is required for the model to operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. A missing local object still fails when a model tool attempts to read it. diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md new file mode 100644 index 0000000000..24335f1da9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md @@ -0,0 +1,37 @@ +# Agent Note:在模型请求中提供由附件提供方解析的图片路径 + +状态:已实现 + +[English](2026-08-21-model-readable-image-paths.md) | 中文 + +## 问题 + +上传图片会变成不透明的持久 `ImageAttachmentRef`。支持图片的模型可以收到请求预览,但原有描述没有给出供后续图片操作使用的文件系统位置。因此 agent 会搜索工作区和临时目录,或询问用户文件在哪里。请求预览和存储的规范化附件都可能与上传图片不同,所以它们的尺寸、格式和字节数不能证明上传图片原本的属性。 + +## 决策 + +`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生不可变对象的绝对路径。请求版本只在序列化期间携带这些事实。没有模型可读本地存储的提供方不返回访问事实。 + +共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及当前可用的提供方规范化路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 + +请求大小 offload 要求提供逐图占位文本函数;原先共享的占位常量及其字节上限包装函数已没有生产调用方,因此删除。DeepSeek 和 pi-ai 会把每个省略位置替换为该图片自己的附件身份和当前访问事实,无需读取或转换省略对象。Offload 的选择、字节计量和按固定步长变化的前缀行为保持不变。 + +描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。访问解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 + +绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。因此,同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径。附件对象保持不可变;模型指令要求先复制到可写位置再修改。 + +## 考虑过的替代方案 + +**把绝对路径持久保存到 `ImageAttachmentRef`。** 移动会话、更改 `DSH_HOME` 或挂载其他提供方后,持久宿主路径会失效。请求时解析可以保持历史可移植。 + +**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。该事实属于附件提供方。 + +**增加专用裁剪或恢复工具。** 把规范化对象复制出来后,标准文件系统与图片工具已经可以处理它。新增工具会增加模型 schema 和访问策略范围,但路径发现不需要它。 + +## 验证 + +包测试覆盖提供方访问默认值、本地摘要到路径的解析、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 + +## 后果 + +所选模型提供方会收到此前只在本地存在的宿主路径。模型需要该信息才能操作存储图片,并且路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。本地对象缺失时,模型工具读取它仍会失败。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index aed3574106..ea79995c64 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: a845fe22e13ed085765668c7ec8d54d6bbdf129a -config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e +config-catalog.md: ffea1ee18359fd6f7cc9171d103e143b8bcd9e5b +config-catalog.zh.md: c447104fcb00d0df3625f6cae89ca128bfa44b57 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a845fe22e1..ffea1ee183 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -317,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) @@ -941,12 +941,10 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte cap for one deterministic request preview. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 39ba9d4836..c447104fcb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -319,7 +319,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) @@ -943,12 +943,10 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte cap for one deterministic request preview. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } ``` diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index b93c9ef1ca..17c9d1bf47 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: e6d0a53db2827a38a1535380319b6220aa37f0a4 -attachment.zh.md: 8328ec610d4d68624f75f00d6a397b13fdf31c4e +attachment.md: fef6b3ad40f424a82049cddc761efa483fdda6f3 +attachment.zh.md: def50f79f33cec9b9262823d481a2d03429f0845 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index e6d0a53db2..fef6b3ad40 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen ## Identity and verified metadata -`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. Consumers call `imageAccess()` when they need a path resolved by the mounted provider. ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -93,6 +93,14 @@ interface StoredImageAttachment { } ``` +```ts type-equiv +/** Provider-resolved ways for model tools to access one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -110,6 +118,8 @@ interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef + /** Transient provider access facts; never persisted in the durable reference. */ + access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -125,7 +135,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageAccess()` resolves current-provider access facts without storing host paths in session data. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -176,6 +186,13 @@ abstract saveImage(input: SaveImageAttachment): Promise */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise +/** + * Resolve provider-specific model-tool access without adding host facts to session history. + * @param ref - durable normalized attachment reference. + * @returns current-provider access facts, or undefined when this backend exposes no local path. + */ +imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 8328ec610d..def50f79f3 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -10,7 +10,7 @@ ## 标识与经过校验的元数据 -`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。 +`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方需要路径时调用 `imageAccess()`,由当前挂载的提供方负责解析。 ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -93,6 +93,14 @@ interface StoredImageAttachment { } ``` +```ts type-equiv +/** Provider-resolved ways for model tools to access one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -110,6 +118,8 @@ interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef + /** Transient provider access facts; never persisted in the durable reference. */ + access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -125,7 +135,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageAccess()` 解析当前提供方的访问信息,无需把宿主路径写入会话数据。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -176,6 +186,13 @@ abstract saveImage(input: SaveImageAttachment): Promise */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise +/** + * Resolve provider-specific model-tool access without adding host facts to session history. + * @param ref - durable normalized attachment reference. + * @returns current-provider access facts, or undefined when this backend exposes no local path. + */ +imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e0946004aa..3e8f3aa982 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -16,7 +16,6 @@ import { } from '@deepseek-ai/dsh-acp-snapshot' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' /** * The acp-agent example's snapshot suite: the scenario table for @@ -807,16 +806,36 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests expect(result.stderr).toBe('') expect(requests).toHaveLength(2) expect(fileRequests).toEqual([{ method: 'POST', path: '/files', bytes: 69 }]) + const attachmentDigest = 'b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' + const attachmentId = `sha256:${attachmentDigest}` + const accessText = (cwd: string): string => { + const attachmentPath = join( + cwd, + '.dsh', + 'attachments', + 'v1', + 'objects', + attachmentDigest.slice(0, 2), + attachmentDigest, + ) + return ` Normalized copy (read-only; may be resized or re-encoded): ${JSON.stringify(attachmentPath)} (1x1px, image/png).` + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .png before editing.' + } + const normalizedAccess = accessText(result.cwd) + const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` + const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` + const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` + .replaceAll(result.cwd, '{{cwd}}') const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toEqual([ { type: 'text', text: 'Compare the older image ' }, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImage }, { type: 'text', text: ' with the newer image ' }, { type: 'text', - text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' - + 'request image 1x1px.', + text: `\n${imageHandle}`, }, { type: 'file', file_id: 'file-api-snapshot-1' }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, @@ -839,7 +858,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests expect(followup).toEqual([ { role: 'user', - content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`, + content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`, }, { role: 'user', @@ -860,7 +879,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests role: 'tool', tool_call_id: 'native-read-image', content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' - + '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; request image 1x1px.', + + `\n${normalizedToolImageHandle}`, }, { role: 'user', @@ -891,15 +910,12 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests expect(requests).toHaveLength(3) const fallbackMessages = requests[2]?.messages as { content?: unknown }[] | undefined const fallbackInput = fallbackMessages?.find(message => JSON.stringify(message.content).includes('[image omitted')) + const fallbackAccess = accessText(fallback.cwd) expect(fallbackInput?.content).toEqual([ { type: 'text', text: 'Compare the older image ' }, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` }, { type: 'text', text: ' with the newer image ' }, - { - type: 'text', - text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' - + 'request image 1x1px.', - }, + { type: 'text', text: `\nImage ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, ]) diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 3698abdcb2..a82a48a906 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-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/attachment/attachment-local/README.md -README.md: 3ed4ab3251b0a609807c76930226bec63f0164cd -README.zh.md: 85abd10389acc46c2d89dd85628f5d201b089710 +README.md: e23fe75a32a873f3f3762c14060c92c66be3a12a +README.zh.md: 646d1d8f26df382be7b0c2c18f2b6ed72661f3a4 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 3ed4ab3251..e23fe75a32 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -8,11 +8,11 @@ Admission accepts at most 20 images and 200MiB of encoded source bytes per messa Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. -`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the absolute normalized-object path from that reference and the current provider root. The path is host-specific, read-only, and absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. ## Model Experience -Indirectly, through durable replay of historical user images and structured model image output after restart and fork. +Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, current read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. #### KV Cache effect diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 85abd10389..646d1d8f26 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -8,11 +8,11 @@ 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 根据该引用和当前提供方根目录派生规范化对象的绝对路径。该路径属于当前宿主,只供读取,不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 ## 模型体验 -该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。 +该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 #### KV 缓存影响 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index e9a1145ba5..31d9f38014 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { + ImageAttachmentAccess, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, @@ -15,7 +16,7 @@ import type { import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' -import { commitPreparedImageFile, prepareImageFile, readImageFile, validateImageFile } from './store.ts' +import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' import { readRequestImageFile, requestImageVariantId } from './request-image.ts' export { canPassThroughNormalization, normalizeImage } from './normalization.ts' @@ -207,6 +208,10 @@ export class LocalAttachmentStore extends AttachmentStore { return readImageFile(this.root, ref, signal) } + override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess { + return { readonlyPath: normalizedImagePath(this.root, ref) } + } + override async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, @@ -230,12 +235,15 @@ export class LocalAttachmentStore extends AttachmentStore { operation = undefined } if (operation === undefined) { - const shared = new SharedRequest(sharedSignal => this.compression.run(async () => readRequestImageFile( - this.root, - stored ?? await this.readImage(ref, sharedSignal), - policy, - sharedSignal, - ))) + const shared = new SharedRequest(sharedSignal => this.compression.run(async () => ({ + ...await readRequestImageFile( + this.root, + stored ?? await this.readImage(ref, sharedSignal), + policy, + sharedSignal, + ), + access: this.imageAccess(ref), + }))) operation = shared this.requestInflight.set(key, shared) void shared.promise.finally(() => { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 5fbb8e9201..266d4f8e34 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -36,16 +36,23 @@ function displayName(value: string | undefined): string | undefined { return clean === '' ? undefined : clean } -function objectPath(root: string, sha256: string): string { - return join(root, 'objects', sha256.slice(0, 2), sha256) -} - function ensureReference(ref: ImageAttachmentRef): string { const match = ID_PATTERN.exec(String(ref.attachmentId)) if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') return match[1] } +/** + * Derive the absolute immutable-object path for one normalized attachment. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable normalized attachment reference. + * @returns provider-local path without reading the object. + */ +export function normalizedImagePath(root: string, ref: ImageAttachmentRef): string { + const sha256 = ensureReference(ref) + return join(root, 'objects', sha256.slice(0, 2), sha256) +} + async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], @@ -199,7 +206,7 @@ export async function commitPreparedImageFile( await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) - const target = objectPath(root, sha256) + const target = normalizedImagePath(root, prepared.ref) let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) @@ -275,7 +282,7 @@ export async function readImageFile( const sha256 = ensureReference(ref) let data: Uint8Array try { - data = new Uint8Array(await readFile(objectPath(root, sha256), { signal })) + data = new Uint8Array(await readFile(normalizedImagePath(root, ref), { signal })) } catch (error) { signal?.throwIfAborted() if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index f8deea3c5c..e5c409b1d6 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -37,6 +38,21 @@ describe('local attachment service', () => { maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) expect(service.imageCompressionConcurrency).toBe(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY) + const ref = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } + expect(service.imageAccess(ref).readonlyPath).toBe(join( + service.root, + 'objects', + 'aa', + 'a'.repeat(64), + )) + expect(() => service.imageAccess({ ...ref, attachmentId: AttachmentId('invalid') })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' })) }) it('resolves and validates the instance image-compression concurrency', () => { @@ -57,6 +73,18 @@ describe('local attachment service', () => { )) const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) + const access = service.imageAccess(ref) + expect(access.readonlyPath).toBe(join( + dshHome, + 'attachments', + 'v1', + 'objects', + String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 2), + String(ref.attachmentId).slice('sha256:'.length), + )) + await expect(readFile(access.readonlyPath)).resolves.toEqual(Buffer.from(data)) + const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) + expect(request.access).toEqual(access) } finally { await rm(dshHome, { recursive: true, force: true }) } diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index e27f25e933..1a55f27a2c 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: 3ad568c7308f1ab85cb4af3fcc2afd3cba9a611a -README.zh.md: fadbb1c5bbf097c599da651055d63a1ed64cd579 +README.md: 63c6a41fca3d0d7ea87d9f30e682e7e4e7aa5906 +README.zh.md: 4eb08dd169e1d0f2b20ada050513caaf88a84891 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 3ad568c730..63c6a41fca 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,15 +2,15 @@ English | [中文](README.zh.md) -The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. +The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, local storage paths, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageAccess` resolves optional current-provider access facts, such as an absolute read-only path, without adding host-specific data to the durable reference. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. ## Model Experience -Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. A backend that offers model-tool access can also expose its current read-only normalized path; the descriptor states that normalization may have resized or re-encoded the upload. #### KV Cache effect diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index fadbb1c5bb..4eb08dd169 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 +持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL、本地存储路径或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageAccess` 解析当前提供方可选的访问事实,例如绝对只读路径,同时避免把宿主信息加入持久引用。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 ## 模型体验 -该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。 +该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。支持模型工具访问的后端还可以公开当前规范化附件的只读路径;描述会说明规范化过程可能缩小或重新编码上传图片。 #### KV 缓存影响 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 8b54926efa..e10ce2d214 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -4,6 +4,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { AttachmentError } from './error.ts' import type { ImageAttachmentLimits, + ImageAttachmentAccess, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, @@ -18,6 +19,7 @@ export { admitEncodedImages } from './admission.ts' export type { AttachmentId as AttachmentIdType, EncodedImageAttachment, + ImageAttachmentAccess, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, @@ -107,6 +109,16 @@ export abstract class AttachmentStore extends Service { */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + /** + * Resolve provider-specific model-tool access without adding host facts to session history. + * @param ref - durable normalized attachment reference. + * @returns current-provider access facts, or undefined when this backend exposes no local path. + */ + imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { + void ref + return undefined + } + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index e23a7a7d4c..0b7d382b64 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -67,6 +67,12 @@ export interface StoredImageAttachment { data: Uint8Array } +/** Provider-resolved ways for model tools to access one normalized attachment. */ +export interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} + /** Deterministic request-image policy selected by one exact model route. */ export interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ @@ -81,6 +87,8 @@ export interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef + /** Transient provider access facts; never persisted in the durable reference. */ + access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index be784f0276..a28bcd6c1c 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -146,6 +146,12 @@ describe('AttachmentStore.readImageRequest', () => { controller.abort(reason) expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) + + it('exposes no provider-specific path by default', async () => { + const store = new RecordingStore(new Context()) + const ref = await store.saveImage(image(1)) + expect(store.imageAccess(ref)).toBeUndefined() + }) }) describe('isImageAdmissionError', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6f7d362aa8..197ff6fadd 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -449,6 +449,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the verified bytes and normalized attachment reference.', throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, + { + signature: 'imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined', + description: 'Resolve provider-specific model-tool access without adding host facts to session history.', + parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }], + returns: 'current-provider access facts, or undefined when this backend exposes no local path.', + }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', @@ -3713,6 +3719,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GrantRecord', declaration: 'export interface GrantRecord {\n readonly kind: \'grant\';\n readonly payload: unknown;\n}', }, + { + name: 'ImageAttachmentAccess', + declaration: 'export interface ImageAttachmentAccess {\n readonlyPath: string;\n}', + }, { name: 'ImageAttachmentLimits', declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n maxImageDimension: number;\n mediaTypes: readonly ImageMediaType[];\n}', @@ -4219,7 +4229,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestImageAttachment', - declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', + declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n access?: ImageAttachmentAccess;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', }, { name: 'RequestRunOutcome', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 8076a692e6..dc0fe9ccb6 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 7d50a8e99863637a06abf49d26a6eeb419cf1bbd -README.zh.md: c1571149529a2d4e54d67b10f63b60bb2aa1abfd +README.md: 31aa82dcc0dde68b7ed23edcd0dc477320d55505 +README.zh.md: dad1dbcea639130ff55430684d6478a86717154c diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7d50a8e998..31aa82dcc0 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -52,9 +52,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id, actual request dimensions, and the local normalized-object path when its attachment provider exposes one. The text marks that path read-only, gives the matching extension for a writable copy, and states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. -`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. +`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Each removed image becomes its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only local path. This high-watermark projection avoids changing an old request prefix after every new image. Inline fallback has an independent base64 budget. `maxInlineRequestImageBytes` defaults to 20MiB and `inlineImageOffloadByteQuantum` to 10MiB, so a history of 21 one-megabyte base64 payloads removes the oldest 11 and retains 10MiB. The calculation uses base64-expanded lengths. The prepared request versions are reused byte-for-byte; fallback does not decode or compress an image again. Successful mappings created before a later image fails remain indexed for future requests. @@ -115,7 +115,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; a Files resolution failure sends all retained images as inline data URLs instead. An over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles, request-preview dimensions, and the current normalized-object path when available; a Files resolution failure sends all retained images as inline data URLs instead. The descriptor tells the model that this read-only local copy may be resized or re-encoded and must not be used to infer upload properties. An over-budget older image keeps the same access facts in its per-image placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c157114952..dad1dbcea6 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -52,9 +52,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及附件提供方支持时的本地规范化对象路径。文本会把该路径标记为只读,指出复制到可写路径时应使用的匹配扩展名,并说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 -`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 +`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。每张被移除的图片都有自己的模型可见占位文本,其中包含显示名称或附件 ID;如果当前提供方支持,还会包含规范化尺寸、媒体类型和当前只读本地路径。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 内联回退使用独立的 base64 预算。`maxInlineRequestImageBytes` 默认为 20MiB,`inlineImageOffloadByteQuantum` 默认为 10MiB,因此由 21 个 1MiB base64 负载组成的历史会移除最旧的 11 个并保留 10MiB。计算使用 base64 膨胀后的长度。系统逐字节复用已经准备好的请求版本;回退不会再次解码或压缩图片。前面图片已经成功写入的上传映射会保留,供后续请求复用。 @@ -115,7 +115,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;Files 解析失败时,所有保留图片改用内联 data URL。超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄、请求预览尺寸,以及当前可用的规范化对象路径;Files 解析失败时,所有保留图片改用内联 data URL。描述会告诉模型,该本地副本只供读取,可能经过缩小或重新编码,不能据此推断上传图片的属性。超出上限的较旧图片会在自己的占位文本中保留相同的访问事实。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index fe461fdb35..8b4140a7d1 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -8,7 +8,7 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, @@ -58,12 +58,10 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte cap for one deterministic request preview. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } /** @@ -206,10 +204,9 @@ function collectImageRefs( * @internal */ export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { - let maxPixels: number - if (model.imagePixelBudget !== undefined) maxPixels = model.imagePixelBudget - else if (model.imageDetail === 'low') maxPixels = DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - else maxPixels = DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET + const maxPixels = model.imagePixelBudget === 'low' + ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET return { maxPixels, maxBytes: model.imageMaxBytes === undefined @@ -548,6 +545,7 @@ export class DeepSeekAdapter extends LlmAdapter { byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, byteLength: ref => Math.min(ref.bytes, policy.maxBytes), + placeholder: ref => offloadedImageText(ref, attachments?.imageAccess(ref)), }) const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } const requestImages = attachments === undefined || model === undefined diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index e6faad74ce..747e37ca4c 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -151,9 +151,8 @@ const catalogModel: z = z.object({ contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']), - imagePixelBudget: z.number().step(1).min(1), + imagePixelBudget: z.union([z.number().step(1).min(1), 'low']), imageMaxBytes: z.number().step(1).min(1), - imageDetail: z.union(['auto', 'low']), }) export const Config: z = z.object({ @@ -225,13 +224,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`) } const hasImage = inputModalities.includes('image') - if (!hasImage && (model.imagePixelBudget !== undefined - || model.imageMaxBytes !== undefined || model.imageDetail !== undefined)) { + if (!hasImage && (model.imagePixelBudget !== undefined || model.imageMaxBytes !== undefined)) { throw new Error(`llm-deepseek: text-only catalog model "${model.id}" cannot declare image request limits`) } if (model.imagePixelBudget !== undefined + && model.imagePixelBudget !== 'low' && (!Number.isSafeInteger(model.imagePixelBudget) || model.imagePixelBudget <= 0)) { - throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be a positive safe integer`) + throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be "low" or a positive safe integer`) } if (model.imageMaxBytes !== undefined && (!Number.isSafeInteger(model.imageMaxBytes) || model.imageMaxBytes <= 0)) { @@ -248,12 +247,10 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee inputModalities: [...inputModalities], ...hasImage ? { - imagePixelBudget: model.imagePixelBudget - ?? (model.imageDetail === 'low' - ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET), + imagePixelBudget: model.imagePixelBudget === 'low' + ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, - ...model.imageDetail === undefined ? {} : { imageDetail: model.imageDetail }, } : {}, } diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 3b22967d96..e1218ba16c 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -6,7 +6,7 @@ * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { @@ -125,12 +125,13 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { /** Describe the exact request preview and its model-callable coordinate system. */ function imageHandle( + ref: ImageAttachmentRef, version: RequestImageAttachment, precededByContent: boolean, ): WireTextContentPart { return { type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImageHandleText(version)}`, + text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version)}`, } } @@ -154,7 +155,7 @@ async function imageParts( type: 'image_url', image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` }, } - return [imageHandle(version, precededByContent), image] + return [imageHandle(block.attachment, version, precededByContent), image] } /** Convert user or nested tool-result blocks into ordered wire parts. */ @@ -415,6 +416,10 @@ export async function serializeRequestWithImages( ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, + placeholder: (ref) => { + const version = images.requestImages.get(ref.attachmentId) + return offloadedImageText(ref, version?.access) + }, }) const messages: WireMessage[] = [] if (options.system !== undefined) { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 5a32a35664..9408f1595b 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -109,7 +109,7 @@ function attachmentStoreOf( } { const readImageRequest = vi.fn(project) return { - store: { readImageRequest } as unknown as AttachmentStore, + store: { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore, readImageRequest, } } @@ -147,7 +147,7 @@ describe('request image policy', () => { { maxPixels: 640_000, maxBytes: 1024 * 1024 }, ], [ - { id: 'low', imageDetail: 'low' as const }, + { id: 'low', imagePixelBudget: 'low' as const }, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }, ], [ @@ -367,7 +367,7 @@ describe('DeepSeekAdapter against a mock server', () => { role: 'user', content: [ { type: 'text', text: 'describe ' }, - { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request image 1x1px.`) as string }, + { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, { type: 'file', file_id: 'file-api-1' }, ], }], @@ -434,7 +434,7 @@ describe('DeepSeekAdapter against a mock server', () => { })) const body = JSON.stringify(server.requests[0]) - expect(body.match(/older images are omitted first/g)).toHaveLength(11) + expect(body.match(/image omitted to fit request image limits/g)).toHaveLength(11) expect(body.match(/"type":"image_url"/g)).toHaveLength(10) }) @@ -600,7 +600,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(body.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, { type: 'file', file_id: 'file-api-1' }, ], @@ -619,7 +619,7 @@ describe('DeepSeekAdapter against a mock server', () => { { id: 'vision-low', inputModalities: ['text', 'image'], - imageDetail: 'low', + imagePixelBudget: 'low', imageMaxBytes: 512_000, }, { @@ -1907,8 +1907,9 @@ describe('plugin registration and config', () => { }) it.each([ - ['imagePixelBudget', 0, /imagePixelBudget must be a positive safe integer/], - ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be a positive safe integer/], + ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], ] as const)('rejects per-model %s=%s', (field, value, message) => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 4617ebdfed..fd6759c152 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -208,7 +208,7 @@ describe('request-level dynamic configuration', () => { const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2) - expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit') + expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits') expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 968ceabdaf..cb94b3c237 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -367,7 +367,7 @@ describe('image serialization', () => { role: 'user', content: [ { type: 'text', text: 'before' }, - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request image 1x1px`) as string }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, { type: 'file', file_id: 'file-api-image' }, { type: 'text', text: 'after' }, ], @@ -392,7 +392,7 @@ describe('image serialization', () => { expect(wire.messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, { type: 'image_url', image_url: { url } }, ], }]) @@ -411,12 +411,41 @@ describe('image serialization', () => { expect(wire.messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` }, + { + type: 'text', + text: `Image ${ref.attachmentId}; request preview 1x1px. It may be resized or re-encoded; source dimensions, format, and byte size may differ.`, + }, { type: 'file', file_id: 'file-api-image' }, ], }]) }) + it('includes provider-resolved normalized access in a retained image handle', async () => { + const ref = { ...imageRef(), name: 'diagram.png', width: 2048, height: 1024 } + const images = imageOptions([ref]) + const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment + version.width = 1130 + version.height = 565 + version.access = { readonlyPath: '/tmp/dsh/objects/aa/object' } + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), images) + + expect(wire.messages[0]).toMatchObject({ + role: 'user', + content: [{ + type: 'text', + text: expect.stringContaining('Image "diagram.png"') as string, + }, { type: 'file' }], + }) + expect(JSON.stringify(wire.messages[0])).toContain('/tmp/dsh/objects/aa/object') + expect(JSON.stringify(wire.messages[0])).toContain('request preview 1130x565px') + }) + it('rejects an image whose prepared request version is absent', async () => { const ref = imageRef() await expect(serializeMessagesWithImages([createUserMessage({ @@ -546,14 +575,14 @@ describe('image serialization', () => { { role: 'tool', tool_call_id: 'before-system', - content: expect.stringContaining('request image 1x1px') as string, + content: expect.stringContaining('request preview 1x1px') as string, }, expect.objectContaining({ role: 'user' }), { role: 'system', content: 'system history' }, { role: 'tool', tool_call_id: 'before-assistant', - content: expect.stringContaining('request image 1x1px') as string, + content: expect.stringContaining('request preview 1x1px') as string, }, expect.objectContaining({ role: 'user' }), { role: 'assistant', content: 'assistant history' }, @@ -564,6 +593,9 @@ describe('image serialization', () => { const resolveFileId = fileResolver() const png = imageRef('image/png', 3) const jpeg = imageRef('image/jpeg', 3) + const images = imageOptions([png, jpeg], resolveFileId, 4) + const pngVersion = images.requestImages.get(png.attachmentId) as RequestImageAttachment + pngVersion.access = { readonlyPath: '/tmp/dsh/objects/png' } const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ @@ -573,12 +605,15 @@ describe('image serialization', () => { ], source: { kind: 'plugin', plugin: 'test' }, })], - }), imageOptions([png, jpeg], resolveFileId, 4)) + }), images) expect(wire.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { + type: 'text', + text: expect.stringContaining(`image omitted to fit request image limits; ${png.attachmentId}. Normalized copy (read-only; may be resized or re-encoded): "/tmp/dsh/objects/png"`) as string, + }, { type: 'text', text: expect.stringContaining(`Image ${jpeg.attachmentId}`) as string }, { type: 'file', file_id: 'file-api-image' }, ], @@ -598,7 +633,7 @@ describe('image serialization', () => { }), inlineImageOptions([ref], 80, 40)) const content = wire.messages[0]?.content - expect(JSON.stringify(content).match(/older images are omitted first/g)).toHaveLength(11) + expect(JSON.stringify(content).match(/image omitted to fit request image limits/g)).toHaveLength(11) expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10) }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index a7021eb1cd..67377a2daa 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 9ef6596490b614a3d4af3dd6b52eb9b9fe87a335 -README.zh.md: 10f366659a38f52f7700c0db7953b983fd0e623a +README.md: 7f0c2a32315200e3244cb83d396bbf2ee30d728f +README.zh.md: f11d4ba1d991cc70c1967eef8f8b880727df36ce diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 9ef6596490..7f0c2a3231 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id, actual request-image dimensions, and provider-resolved normalized-object path when available. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id, actual request dimensions, and current normalized-object path when available. The descriptor marks that path read-only and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and available local path in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 10f366659a..f11d4ba1d9 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID、实际请求图片尺寸,以及当前提供方支持时的规范化对象路径。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及当前可用的规范化对象路径。描述会把该路径标记为只读,并说明规范化或请求投影可能缩小或重新编码上传图片。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张被 offload 的图片会在替代文本中保留自己的身份和可用本地路径。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 9faf457c9a..db99f86d8e 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentId, @@ -57,7 +57,7 @@ async function userContent( break case 'image': { const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment - content.push({ type: 'text', text: requestImageHandleText(version) }) + content.push({ type: 'text', text: requestImageHandleText(block.attachment, version) }) content.push({ type: 'image', data: Buffer.from(version.data).toString('base64'), @@ -231,6 +231,7 @@ async function toPiContextWithImages( ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => Math.min(ref.bytes, requestImagePolicy.maxBytes), + placeholder: ref => offloadedImageText(ref, attachments.imageAccess(ref)), }) const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal) const exactMessages = offloadRequestImagesWithPolicy(requestMessages, { @@ -238,6 +239,7 @@ async function toPiContextWithImages( ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes, + placeholder: ref => offloadedImageText(ref, requestImages.get(ref.attachmentId)?.access), }) const toolNames = new Map() const messages: PiMessage[] = [] diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 026ca2c608..a971b80c85 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -6,7 +6,7 @@ import type { ImageRequestPolicy, RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' +import { CallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { toPiContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' @@ -43,7 +43,7 @@ function projectionStore( Promise.resolve(requestImage(value, Uint8Array.of(1))) )), ): AttachmentStore { - return { readImageRequest } as unknown as AttachmentStore + return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore } const attachments = projectionStore() @@ -174,6 +174,26 @@ describe('pi-ai request context conversion', () => { ]) }) + it('uses the shared normalized-path description for retained images', async () => { + const named = { ...ref, name: 'chart.png', width: 2048, height: 1024 } + const store = projectionStore(value => Promise.resolve({ + ...requestImage(value, Uint8Array.of(1)), + width: 1130, + height: 565, + access: { readonlyPath: '/tmp/dsh/objects/aa/object' }, + })) + const context = await toPiContext(request([user([{ type: 'image', attachment: named }])]), store) + expect(context.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining('Image "chart.png"') as string }, + { type: 'image' }, + ], + }) + expect(JSON.stringify(context.messages[0])).toContain('/tmp/dsh/objects/aa/object') + expect(JSON.stringify(context.messages[0])).toContain('request preview 1130x565px') + }) + it('recursively converts nested tool-result text and images', async () => { const callId = CallId('nested-call') const context = await toPiContext(request([user([{ @@ -252,7 +272,7 @@ describe('pi-ai request context conversion', () => { role: 'toolResult', toolCallId: 'shot-call', toolName: 'unknown', - content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + content: [{ type: 'text', text: offloadedImageText(sized) }], isError: false, timestamp: 0, }, @@ -293,7 +313,7 @@ describe('pi-ai request context conversion', () => { expect(context.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImageText(old) }, { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, { type: 'image' }, ], @@ -302,6 +322,26 @@ describe('pi-ai request context conversion', () => { expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent) }) + it('uses the generated request access when exact encoded bytes require offload', async () => { + const sized: ImageAttachmentRef = { ...ref, bytes: 3 } + const access = { readonlyPath: '/tmp/dsh-normalized-image' } + const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve({ + ...requestImage(value, Uint8Array.of(1, 2, 3, 4)), + access, + })) + + const context = await toPiContext(request([ + user([{ type: 'image', attachment: sized }]), + ]), projectionStore(readImageRequest), undefined, 4) + + expect(context.messages).toEqual([{ + role: 'user', + content: offloadedImageText(sized, access), + timestamp: 0, + }]) + expect(readImageRequest).toHaveBeenCalledTimes(1) + }) + it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const exact = await toPiContext(request([ @@ -330,7 +370,7 @@ describe('pi-ai request context conversion', () => { ]), store, undefined, 8) // All-text content collapses to the string form; the placeholder still reaches the model. expect(oversized.messages).toEqual([ - { role: 'user', content: OFFLOADED_IMAGE_TEXT, timestamp: 0 }, + { role: 'user', content: offloadedImageText({ ...ref, bytes: 300 }), timestamp: 0 }, ]) expect(readImageRequest).not.toHaveBeenCalled() }) @@ -351,7 +391,7 @@ describe('pi-ai request context conversion', () => { const expected = [{ role: 'user', content: [ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImageText(sized) }, { type: 'text', text: expect.stringContaining(`Image ${sized.attachmentId}`) as string }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, ], diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index ed4df11a4d..1d53dbdab0 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -63,7 +63,7 @@ function attachmentStore(readImageRequest: ( policy: ImageRequestPolicy, signal?: AbortSignal, ) => Promise): AttachmentStore { - return { readImageRequest } as unknown as AttachmentStore + return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore } describe('toPiContext', () => { diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 08820bbfd9..3ac89ec262 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: 59c5303bdae8d6391f3bb1595a527d677e2378bc -README.zh.md: 313fc23590de2119bf07dd5c4c4fafe9daa94c56 +README.md: d08d28497c4821c578fac43515b3bebfa4ed8131 +README.zh.md: 3d7a183aa2319acd869d8e4bc68ec9493c3ea322 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 59c5303bda..d08d28497c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -57,7 +57,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o Message content is an array of typed blocks: `text`, `reasoning`, `image`, `tool-call`, `tool-result`. An `ImageBlock` carries only a durable `ImageAttachmentRef`; provider bytes and request dimensions are resolved later. The union remains merge-extensible through `ContentBlockMap`, so plugins can add further block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. -Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length. +Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content. @@ -91,11 +91,11 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l ## Model Experience -None, as the service adds no model-bound text, schema, or message; it only materializes and logs an adapter-configured reasoning effort. +None, as adapters choose when to add the shared image descriptors and per-image placeholders exported by this package, while the LLM service itself only materializes and logs adapter-configured request facts. #### KV Cache effect -Pass-through; the registry preserves the assembled request prefix, while the selected adapter and provider own cache reuse and routing boundaries. +Reasoning-effort materialization preserves the assembled request prefix. Image descriptors add deterministic text beside each image, and a request-limit transition replaces the deterministic oldest prefix with per-image text. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 313fc23590..3d7a183aa2 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -57,7 +57,7 @@ 消息内容是类型化内容块数组:`text`、`reasoning`、`image`、`tool-call`、`tool-result`。`ImageBlock` 只携带持久 `ImageAttachmentRef`;提供方字节和请求尺寸之后再解析。联合仍从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加其他块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型或提供方间恢复或转换该状态。 -每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度。 +每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。 流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。 @@ -93,11 +93,11 @@ ## 模型体验 -无。服务不添加任何与模型绑定的文本、schema 或消息;它只会填入并记录适配器配置的推理强度。 +无。适配器决定何时加入该包导出的共用图片描述和逐图占位文本,LLM 服务本身只会填入并记录适配器配置的请求事实。 #### KV Cache 影响 -透传;注册表保留已组装请求前缀,cache 复用与路由边界属于所选适配器和提供方。 +推理强度填入不会改变已组装的请求前缀。图片描述会在每张图片旁加入确定性文本;请求越过上限时,确定性的最旧前缀会替换为逐图文本。 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 4620275429..10076ad529 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,15 +2,38 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentAccess, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { assertNever } from './never.ts' -/** Model-facing stand-in for an image removed to fit a provider request bound. */ -export const OFFLOADED_IMAGE_TEXT - = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' +function quoted(value: string): string { + return JSON.stringify(value) +} + +function imageIdentity(ref: ImageAttachmentRef): string { + return ref.name === undefined + ? String(ref.attachmentId) + : `${quoted(ref.name)} (${ref.attachmentId})` +} + +function extension(mediaType: ImageMediaType): string { + switch (mediaType) { + case 'image/png': return '.png' + case 'image/jpeg': return '.jpg' + case 'image/webp': return '.webp' + case 'image/gif': return '.gif' + default: return assertNever(mediaType, 'image extension') + } +} + +function normalizedAccessText(ref: ImageAttachmentRef, access: ImageAttachmentAccess): string { + return ` Normalized copy (read-only; may be resized or re-encoded): ${quoted(access.readonlyPath)} (${ref.width}x${ref.height}px, ${ref.mediaType}).` + + ' Source dimensions, format, and byte size may differ.' + + ` Copy to a writable path ending in ${extension(ref.mediaType)} before editing.` +} /** * Stable text shown to a model that cannot accept one durable image reference. - * @param ref - durable master reference omitted from the request. + * @param ref - durable normalized attachment omitted from the request. * @returns deterministic text-only placeholder. */ export function textOnlyImageText(ref: ImageAttachmentRef): string { @@ -19,12 +42,36 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { } /** - * Stable model-facing handle for one exact request image. + * Stable model-facing handle for one exact request image. Identity comes from + * the occurrence's own durable reference: request versions are prepared per + * attachment id, so one shared version may serve occurrences whose display + * names differ. + * @param ref - the occurrence's durable normalized attachment. * @param version - exact request image shown beside the text. * @returns attachment handle and request-image dimensions. */ -export function requestImageHandleText(version: RequestImageAttachment): string { - return `Image ${version.attachment.attachmentId}; request image ${version.width}x${version.height}px.` +export function requestImageHandleText(ref: ImageAttachmentRef, version: RequestImageAttachment): string { + const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.` + return version.access === undefined + ? `${preview} It may be resized or re-encoded; source dimensions, format, and byte size may differ.` + : preview + normalizedAccessText(ref, version.access) +} + +/** + * Stable per-image placeholder for a request-limit omission. + * @param ref - durable normalized attachment omitted from this request. + * @param access - optional provider-resolved path for model tools. + * @returns identity, normalized metadata, and the available recovery path. + */ +export function offloadedImageText( + ref: ImageAttachmentRef, + access?: ImageAttachmentAccess, +): string { + const identity = `image omitted to fit request image limits; ${imageIdentity(ref)}.` + if (access === undefined) { + return `[${identity} No local normalized image path is available; ask the user to attach it again if needed.]` + } + return `[${identity}${normalizedAccessText(ref, access)}]` } /** @@ -57,8 +104,10 @@ export interface RequestImageOffloadPolicy { byteQuantum?: number /** Whether byte accounting uses raw file bytes or inline base64 length. */ representation: 'raw' | 'base64' - /** Resolve the encoded request-version length; omission uses master attachment bytes. */ + /** Resolve the encoded request-version length; omission uses normalized attachment bytes. */ byteLength?: (ref: ImageAttachmentRef) => number + /** Build the model-visible replacement for each omitted attachment. */ + placeholder: (ref: ImageAttachmentRef) => string } /** Collect represented image lengths in request and nested-block order. */ @@ -83,17 +132,18 @@ function collectImageLengths( function replaceOldestImages( blocks: readonly ContentBlock[], remaining: { count: number }, + placeholder: (ref: ImageAttachmentRef) => string, ): ContentBlock[] { let next: ContentBlock[] | undefined for (const [index, block] of blocks.entries()) { if (block.type === 'image' && remaining.count > 0) { remaining.count -= 1 next ??= blocks.slice(0, index) - next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) + next.push({ type: 'text', text: placeholder(block.attachment) }) continue } if (block.type === 'tool-result') { - const content = replaceOldestImages(block.content, remaining) + const content = replaceOldestImages(block.content, remaining, placeholder) if (content !== block.content) { next ??= blocks.slice(0, index) next.push({ ...block, content }) @@ -140,26 +190,6 @@ export function projectImagesForTextModel(messages: readonly Message[]): readonl }) } -/** - * Return transient request messages whose oldest images are replaced until - * their accumulated base64 payload fits the configured bound. The selection - * is deterministic from durable message order and attachment metadata; a - * provider can serialize the returned messages without reading omitted bytes. - * @param messages - complete request history, oldest first. - * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image. - * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees. - */ -export function offloadRequestImages( - messages: readonly Message[], - maxRequestImageBytes: number | undefined, -): readonly Message[] { - return offloadRequestImagesWithPolicy(messages, { - representation: 'base64', - ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, - byteQuantum: 1, - }) -} - /** * Return a deterministic transient projection whose oldest images are replaced * in whole count and byte quanta after a route budget is exceeded. The target @@ -196,7 +226,7 @@ export function offloadRequestImagesWithPolicy( } const remaining = { count } return messages.map((message) => { - const content = replaceOldestImages(message.content, remaining) + const content = replaceOldestImages(message.content, remaining, policy.placeholder) return content === message.content ? message : { ...message, content } }) } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 6a0eb02c63..236cee2297 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -1,18 +1,30 @@ import { describe, expect, it } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import { CallId, createUserMessage, - OFFLOADED_IMAGE_TEXT, - offloadRequestImages, + offloadedImageText, offloadRequestImagesWithPolicy, projectImagesForTextModel, + requestImageHandleText, } from '../src/index.ts' -import type { ContentBlock } from '../src/index.ts' +import type { ContentBlock, Message } from '../src/index.ts' const source = { kind: 'plugin' as const, plugin: 'test' } -function image(bytes: number): ContentBlock { +const OMITTED = '[omitted]' + +function offloadBase64(messages: readonly Message[], maxBytes: number | undefined): readonly Message[] { + return offloadRequestImagesWithPolicy(messages, { + representation: 'base64', + ...maxBytes === undefined ? {} : { maxBytes }, + byteQuantum: 1, + placeholder: () => OMITTED, + }) +} + +function image(bytes: number): Extract { return { type: 'image', attachment: { @@ -25,15 +37,15 @@ function image(bytes: number): ContentBlock { } } -describe('offloadRequestImages', () => { +describe('base64 request-image offload', () => { it('preserves every image when no payload bound is configured', () => { const messages = [createUserMessage({ content: [image(300)], source })] - expect(offloadRequestImages(messages, undefined)).toBe(messages) + expect(offloadBase64(messages, undefined)).toBe(messages) }) it('preserves the original request when its base64 payload fits exactly', () => { const messages = [createUserMessage({ content: [image(3), image(3)], source })] - expect(offloadRequestImages(messages, 8)).toBe(messages) + expect(offloadBase64(messages, 8)).toBe(messages) }) it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => { @@ -43,14 +55,14 @@ describe('offloadRequestImages', () => { content: Array.from({ length: 5 }, () => image(rawImageBytes)), source, })] - expect(offloadRequestImages(exact, maxRequestImageBytes)).toBe(exact) + expect(offloadBase64(exact, maxRequestImageBytes)).toBe(exact) const over = [createUserMessage({ content: [image(rawImageBytes + 1), ...Array.from({ length: 4 }, () => image(rawImageBytes))], source, })] - expect(offloadRequestImages(over, maxRequestImageBytes)[0]?.content).toEqual([ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + expect(offloadBase64(over, maxRequestImageBytes)[0]?.content).toEqual([ + { type: 'text', text: OMITTED }, ...Array.from({ length: 4 }, () => image(rawImageBytes)), ]) }) @@ -69,12 +81,12 @@ describe('offloadRequestImages', () => { createUserMessage({ content: [shared, image(3)], source }), ] - const fitted = offloadRequestImages(messages, 8) + const fitted = offloadBase64(messages, 8) expect(fitted).not.toBe(messages) expect(fitted[0]?.content).toEqual([{ type: 'tool-result', toolCallId: CallId('shot'), - content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + content: [{ type: 'text', text: OMITTED }], }]) expect(fitted[1]?.content).toEqual([shared, image(3)]) expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] }) @@ -82,8 +94,8 @@ describe('offloadRequestImages', () => { it('replaces a single image that cannot fit', () => { const messages = [createUserMessage({ content: [image(300)], source })] - expect(offloadRequestImages(messages, 8)[0]?.content) - .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }]) + expect(offloadBase64(messages, 8)[0]?.content) + .toEqual([{ type: 'text', text: OMITTED }]) }) it('keeps unchanged nested content while replacing a later image', () => { @@ -93,9 +105,9 @@ describe('offloadRequestImages', () => { content: [{ type: 'text' as const, text: 'kept' }], } const messages = [createUserMessage({ content: [nested, image(3)], source })] - expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([ + expect(offloadBase64(messages, 1)[0]?.content).toEqual([ nested, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: OMITTED }, ]) }) }) @@ -109,6 +121,7 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxBytes: 128 * mib, byteQuantum: 64 * mib, + placeholder: () => OMITTED, })[0]?.content expect(project(128)?.filter(block => block.type === 'image')).toHaveLength(128) @@ -124,6 +137,7 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxImages: 600, countQuantum: 20, + placeholder: () => OMITTED, }) expect(projected[0]?.content.filter(block => block.type === 'text')).toHaveLength(20) expect(projected[0]?.content.filter(block => block.type === 'image')).toHaveLength(581) @@ -135,12 +149,135 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxBytes: 3, byteLength: () => 2, + placeholder: () => OMITTED, }) expect(projected[0]?.content).toEqual([ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: OMITTED }, image(100), ]) }) + + it('builds a distinct placeholder from each omitted attachment', () => { + const first = image(3) + const second = image(3) + first.attachment = { ...first.attachment, name: 'first.png' } + second.attachment = { ...second.attachment, name: 'second.png' } + const projected = offloadRequestImagesWithPolicy([ + createUserMessage({ content: [first, second], source }), + ], { + representation: 'raw', + maxBytes: 3, + placeholder: ref => `omitted:${ref.name}`, + }) + expect(projected[0]?.content).toEqual([ + { type: 'text', text: 'omitted:first.png' }, + second, + ]) + }) +}) + +describe('model-facing image access', () => { + it('describes the request preview, immutable normalized path, and source uncertainty', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 4_000, + width: 2048, + height: 1536, + name: 'source "map".png', + } + const version = { + variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`), + attachment, + access: { readonlyPath: '/tmp/.dsh/attachments/v1/objects/bb/object' }, + data: Uint8Array.of(1), + mediaType: 'image/png' as const, + bytes: 1, + width: 923, + height: 692, + depth: 'uchar' as const, + space: 'srgb' as const, + hasAlpha: true, + } + expect(requestImageHandleText(attachment, version)).toBe( + `Image "source \\"map\\".png" (${attachment.attachmentId}); request preview 923x692px.` + + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/.dsh/attachments/v1/objects/bb/object" (2048x1536px, image/png).' + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .png before editing.', + ) + }) + + it('names each occurrence from its own reference when one prepared version is shared', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 4_000, + width: 8, + height: 8, + name: 'second.png', + } + const version = { + variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`), + attachment, + data: Uint8Array.of(1), + mediaType: 'image/png' as const, + bytes: 1, + width: 8, + height: 8, + depth: 'uchar' as const, + space: 'srgb' as const, + hasAlpha: false, + } + expect(requestImageHandleText({ ...attachment, name: 'first.png' }, version)) + .toContain('"first.png"') + }) + + it('keeps a useful omission identity with and without a local path', () => { + const ref = { + attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), + mediaType: 'image/jpeg' as const, + bytes: 10, + width: 10, + height: 5, + name: 'photo.jpg', + } + expect(offloadedImageText(ref)).toContain('No local normalized image path is available') + expect(offloadedImageText(ref, { readonlyPath: '/tmp/object' })).toBe( + `[image omitted to fit request image limits; "photo.jpg" (${ref.attachmentId}).` + + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/object" (10x5px, image/jpeg).' + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .jpg before editing.]', + ) + }) + + it.each([ + ['image/png', '.png'], + ['image/jpeg', '.jpg'], + ['image/webp', '.webp'], + ['image/gif', '.gif'], + ] as const)('names the writable extension for %s', (mediaType, suffix) => { + const ref = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + mediaType, + bytes: 1, + width: 1, + height: 1, + } + expect(offloadedImageText(ref, { readonlyPath: '/tmp/object' })) + .toContain(`writable path ending in ${suffix}`) + }) + + it('rejects a media type that escaped the closed union at runtime', () => { + const ref = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + mediaType: 'image/tiff' as unknown as ImageMediaType, + bytes: 1, + width: 1, + height: 1, + } + expect(() => offloadedImageText(ref, { readonlyPath: '/tmp/object' })) + .toThrow('unreachable variant in image extension: "image/tiff"') + }) }) describe('projectImagesForTextModel', () => { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d8bed47236..31217c1f31 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -335,6 +335,7 @@ export const LINK_MAP: Readonly> = { ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', EncodedImageAttachment: 'attachment.md', + ImageAttachmentAccess: 'attachment.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', RequestImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 06cce11143..0195425c04 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -931,6 +931,11 @@ "symbol": "StoredImageAttachment", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "ImageAttachmentAccess", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "ImageRequestPolicy", From 7f4cf99eeb919c1c00950498c80bdeecc20dcae0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 13:11:04 +0800 Subject: [PATCH 033/138] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=AE=BF=E9=97=AE=E4=B8=8E=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-08-21-model-readable-image-paths.i18n.yaml | 4 ++-- .../2026-08-21-model-readable-image-paths.md | 10 ++++++---- ...2026-08-21-model-readable-image-paths.zh.md | 10 ++++++---- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/attachment.i18n.yaml | 4 ++-- docs/subsystems/attachment.md | 1 + docs/subsystems/attachment.zh.md | 1 + docs/subsystems/filesystem.i18n.yaml | 4 ++-- docs/subsystems/filesystem.md | 14 ++++++++++++-- docs/subsystems/filesystem.zh.md | 14 ++++++++++++-- .../attachment-local/README.i18n.yaml | 4 ++-- packages/attachment/attachment-local/README.md | 7 +++---- .../attachment/attachment-local/README.zh.md | 7 +++---- .../attachment/attachment-local/package.json | 3 +++ .../attachment/attachment-local/src/index.ts | 18 +++++++++++------- .../attachment/attachment-local/src/store.ts | 3 +++ .../attachment-local/tests/index.spec.ts | 14 +++++++------- .../attachment-local/tests/store.spec.ts | 5 ++++- .../attachment/attachment-local/tsconfig.json | 1 + packages/attachment/attachment/src/index.ts | 1 + packages/e2b/fs-e2b/tests/filesystem.spec.ts | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 7 +++++++ packages/fs/fs-local/README.i18n.yaml | 4 ++-- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/README.zh.md | 2 +- packages/fs/fs-local/src/index.ts | 4 ++++ packages/fs/fs-local/tests/filesystem.spec.ts | 6 ++++++ packages/fs/fs/README.i18n.yaml | 4 ++-- packages/fs/fs/README.md | 7 ++++--- packages/fs/fs/README.zh.md | 7 ++++--- packages/fs/fs/src/index.ts | 13 +++++++++++++ packages/fs/fs/tests/service.spec.ts | 1 + packages/llm/llm-deepseek/src/index.ts | 3 +++ .../llm/llm-deepseek/tests/adapter.spec.ts | 14 ++++++++++++++ pnpm-lock.yaml | 6 ++++++ 37 files changed, 155 insertions(+), 59 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml index 16831b0ca3..314dabaa74 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.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-21-model-readable-image-paths.md -2026-08-21-model-readable-image-paths.md: 4c2610ad48e47d2642e32ae53048d0897abd64e9 -2026-08-21-model-readable-image-paths.zh.md: 24335f1da99c382291988d5862bdd52e8d5d961c +2026-08-21-model-readable-image-paths.md: 729e6347e8623694e1cc4f4779bf71447006cb08 +2026-08-21-model-readable-image-paths.zh.md: d67817cf699afb6b094524388dd6d5a48a653e90 diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md index 4c2610ad48..729e6347e8 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md @@ -10,7 +10,7 @@ An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable ## Decision -`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute immutable-object path from the resolved `DSH_HOME`, attachment storage version, and validated digest. A request version carries these facts transiently for serialization. A provider without model-readable local storage returns no access facts. +`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute object path from the resolved `DSH_HOME`, attachment storage version, and validated digest, then asks the mounted `ctx.fs` to map that host file into its execution world. `FileSystem.processPathFromHostPath(hostPath)` returns no mapping by default; the host-backed provider returns the absolute process path, while E2B and other remote providers return no access facts. A request version carries available facts transiently for serialization. The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the provider-resolved normalized path when available. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. @@ -18,7 +18,7 @@ Request-size offload requires a per-image placeholder function; the previous sha Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Access resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. -Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the provider mounted for the current process. Restoring the same session with a different `DSH_HOME` therefore produces the path that is valid on that host. The attachment object remains immutable; model instructions require a writable copy for modifications. +Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the providers mounted for the current process. Restoring the same session with a different `DSH_HOME` produces the path valid on that host; restoring it with a remote execution world that has no shared mount produces no path. Published attachment objects use owner-read-only mode, including deduplicated objects, and model instructions require a writable copy for modifications. ## Alternatives considered @@ -26,12 +26,14 @@ Absolute paths stay out of session events. Model-visible path text is reconstruc **Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns this fact. +**Infer path sharing from the filesystem provider's package or class name.** Provider identity does not establish that a host file is mounted into its execution world. The filesystem provider instead answers the exact mapping question and can support an explicit shared mount without changing the attachment provider. + **Add a dedicated crop or recovery tool.** Standard filesystem and image tools can operate after copying the normalized object. A new tool adds a model schema and access-policy surface without being necessary for path discovery. ## Verification -Package tests cover provider access defaults, local digest-to-path resolution, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. +Package tests cover provider access defaults, host-backed path mapping, absence without a mapped filesystem, digest-to-path resolution, owner-read-only publication and deduplication, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. ## Consequences -The selected model provider receives a host path that was previously local-only. This disclosure is required for the model to operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. A missing local object still fails when a model tool attempts to read it. +When the execution world maps the attachment object, the selected model provider receives its path. This disclosure lets the model operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. Remote execution worlds without a shared mount receive the existing no-path recovery text. A missing local object still fails when a model tool attempts to read it. diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md index 24335f1da9..d67817cf69 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md @@ -10,7 +10,7 @@ ## 决策 -`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生不可变对象的绝对路径。请求版本只在序列化期间携带这些事实。没有模型可读本地存储的提供方不返回访问事实。 +`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生对象的绝对路径,再要求当前 `ctx.fs` 把这个宿主文件映射到其执行世界。`FileSystem.processPathFromHostPath(hostPath)` 默认不返回映射。宿主后端返回绝对进程路径,E2B 和其他远程后端不返回访问事实。请求版本只在序列化期间携带当前可用的事实。 共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及当前可用的提供方规范化路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 @@ -18,7 +18,7 @@ 描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。访问解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 -绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。因此,同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径。附件对象保持不可变;模型指令要求先复制到可写位置再修改。 +绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径;在没有共享挂载的远程执行世界中恢复时不会得到路径。发布的附件对象采用仅所有者可读的权限,去重对象也执行该权限。模型指令要求先复制到可写位置再修改。 ## 考虑过的替代方案 @@ -26,12 +26,14 @@ **让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。该事实属于附件提供方。 +**根据文件系统提供方的包名或类名判断是否共享路径。** 提供方身份不能证明宿主文件已挂载到其执行世界。文件系统提供方直接回答具体路径能否映射,也允许未来的显式共享挂载在不修改附件提供方的情况下返回映射。 + **增加专用裁剪或恢复工具。** 把规范化对象复制出来后,标准文件系统与图片工具已经可以处理它。新增工具会增加模型 schema 和访问策略范围,但路径发现不需要它。 ## 验证 -包测试覆盖提供方访问默认值、本地摘要到路径的解析、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 +包测试覆盖提供方访问默认值、宿主后端路径映射、没有文件系统映射时省略路径、本地摘要到路径的解析、仅所有者可读的发布与去重、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 ## 后果 -所选模型提供方会收到此前只在本地存在的宿主路径。模型需要该信息才能操作存储图片,并且路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。本地对象缺失时,模型工具读取它仍会失败。 +执行世界能够映射附件对象时,所选模型提供方会收到该路径。模型可借此操作存储图片,路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。没有共享挂载的远程执行世界会收到原有的无路径恢复文本。本地对象缺失时,模型工具读取它仍会失败。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ea79995c64..c0ac258326 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: ffea1ee18359fd6f7cc9171d103e143b8bcd9e5b -config-catalog.zh.md: c447104fcb00d0df3625f6cae89ca128bfa44b57 +config-catalog.md: 7af4d4e47866736ca5f100d07dbd04f8341ae4a0 +config-catalog.zh.md: 9e1d3739d25b309172f2a9261cb10e8c5775af12 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ffea1ee183..7af4d4e478 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -317,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c447104fcb..9e1d3739d2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -319,7 +319,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 17c9d1bf47..fc14d236c4 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: fef6b3ad40f424a82049cddc761efa483fdda6f3 -attachment.zh.md: def50f79f33cec9b9262823d481a2d03429f0845 +attachment.md: d855a31c4b463098e30652a30e807d648298c952 +attachment.zh.md: 314a6486266fe27d5e5fda17ae1b0c09ee65c5f3 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index fef6b3ad40..d855a31c4b 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -190,6 +190,7 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise @@ -313,6 +313,16 @@ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): P */ abstract processPath(target: FsTarget): string +/** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ +processPathFromHostPath(hostPath: string): string | undefined + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index ecbb9e83cf..ea5b6f7a0d 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -12,7 +12,7 @@ 每个操作首先将用户提供的路径解析为不透明的后端目标。消费方可以显示 `displayPath`,但禁止解析 `targetKey`(一个品牌化的不透明 id),也不得假设它是本地绝对路径。 -与文件系统共享执行世界的消费方通过提供方获取跨能力坐标,而不是解释该身份:`processPath(target)` 返回子进程可以打开的规范化绝对路径,`fileUrl(target)` 返回采用提供方平台语法的 `file:` URI,`contains(parent, child)` 则检查规范化身份相等或后代包含关系。 +与文件系统共享执行世界的消费方通过提供方获取跨能力坐标,而不是解释该身份:`processPath(target)` 返回子进程可以打开的规范化绝对路径;`processPathFromHostPath(hostPath)` 只在该执行世界共享相应宿主文件时映射其绝对路径;`fileUrl(target)` 返回采用提供方平台语法的 `file:` URI;`contains(parent, child)` 检查规范化身份相等或后代包含关系。 ```ts type-equiv /** @@ -275,7 +275,7 @@ type FsErrorCode = ## 服务与插件 -`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`processPathFromHostPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**。它通过 `fs/*` 事件门禁添加策略,根据未见、缺失或存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取、写入或编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 @@ -313,6 +313,16 @@ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): P */ abstract processPath(target: FsTarget): string +/** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ +processPathFromHostPath(hostPath: string): string | undefined + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index a82a48a906..dd994eee03 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-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/attachment/attachment-local/README.md -README.md: e23fe75a32a873f3f3762c14060c92c66be3a12a -README.zh.md: 646d1d8f26df382be7b0c2c18f2b6ed72661f3a4 +README.md: c93f32ede4599d8e07536062dd7d44134bea16b0 +README.zh.md: f06c3aeec937e0eb09ea0aa2ef25d41119e54aa8 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index e23fe75a32..c93f32ede4 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, a synced temporary file, an atomic exclusive hard-link publish, owner-read-only object permissions, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. -`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the absolute normalized-object path from that reference and the current provider root. The path is host-specific, read-only, and absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the normalized object's absolute host path and asks the mounted `ctx.fs` to map that file into its execution world. Host-backed filesystems return a read-only process path; remote filesystems without a shared mount return no path. The path is absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. ## Model Experience -Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, current read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. +Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, available read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. #### KV Cache effect @@ -21,6 +21,5 @@ Normalization and request projection are deterministic. An unchanged attachment ## Known Limitations and Deferred Work - Objects are retained indefinitely; reference-aware garbage collection is deferred. -- The local backend assumes the host and provider adapter share this filesystem service. - Animated GIF sources keep only their first frame; animation is outside the version-one image contract. - The normalization and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future normalized attachments or request variants while existing objects stay valid. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 646d1d8f26..f06c3aeec9 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、经过同步的临时文件、原子且排他的硬链接发布、仅所有者可读的对象权限,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 根据该引用和当前提供方根目录派生规范化对象的绝对路径。该路径属于当前宿主,只供读取,不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 先派生规范化对象的绝对宿主路径,再要求当前 `ctx.fs` 把该文件映射到其执行世界。宿主文件系统返回只读进程路径;没有共享挂载的远程文件系统不返回路径。该路径不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 ## 模型体验 -该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 +该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前可用的只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 #### KV 缓存影响 @@ -21,6 +21,5 @@ ## 已知限制与待完成工作 - 对象会无限期保留;基于引用的垃圾回收尚未实现。 -- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 - 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。 - 规范化和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的规范化附件或请求变体产生新地址,已有对象保持有效。 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 194112be29..cb2472f62a 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -23,6 +23,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -33,6 +34,8 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 31d9f38014..6a22038154 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -13,6 +13,7 @@ import type { SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-fs' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' @@ -208,8 +209,10 @@ export class LocalAttachmentStore extends AttachmentStore { return readImageFile(this.root, ref, signal) } - override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess { - return { readonlyPath: normalizedImagePath(this.root, ref) } + override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { + const hostPath = normalizedImagePath(this.root, ref) + const readonlyPath = this.ctx.get('fs')?.processPathFromHostPath(hostPath) + return readonlyPath === undefined ? undefined : { readonlyPath } } override async readImageRequest( @@ -235,15 +238,16 @@ export class LocalAttachmentStore extends AttachmentStore { operation = undefined } if (operation === undefined) { - const shared = new SharedRequest(sharedSignal => this.compression.run(async () => ({ - ...await readRequestImageFile( + const shared = new SharedRequest(sharedSignal => this.compression.run(async () => { + const request = await readRequestImageFile( this.root, stored ?? await this.readImage(ref, sharedSignal), policy, sharedSignal, - ), - access: this.imageAccess(ref), - }))) + ) + const access = this.imageAccess(ref) + return { ...request, ...(access === undefined ? {} : { access }) } + })) operation = shared this.requestInflight.set(key, shared) void shared.promise.finally(() => { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 266d4f8e34..f5463a478f 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -222,6 +222,9 @@ export async function commitPreparedImageFile( const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } + // The hard link and staging entry share one inode, so this applies to both + // names until cleanup and also enforces read-only mode on deduplicated objects. + await chmod(target, 0o400) // Persist the target entry and close a concurrent bucket-creation window // before the reference can reach a session checkpoint. The dedup path // repeats both syncs because it may observe another writer's link before diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index e5c409b1d6..ba18777c75 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -45,12 +46,7 @@ describe('local attachment service', () => { width: 1, height: 1, } - expect(service.imageAccess(ref).readonlyPath).toBe(join( - service.root, - 'objects', - 'aa', - 'a'.repeat(64), - )) + expect(service.imageAccess(ref)).toBeUndefined() expect(() => service.imageAccess({ ...ref, attachmentId: AttachmentId('invalid') })) .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' })) }) @@ -65,8 +61,10 @@ describe('local attachment service', () => { it('saves and reads through the service boundary', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-')) + const ctx = new Context() + new LocalFileSystem(ctx, { cwd: dshHome, diffBasisMaxBytes: 10 * 1024 * 1024 }) try { - const service = new LocalAttachmentStore(new Context(), { dshHome }) + const service = new LocalAttachmentStore(ctx, { dshHome }) const data = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', @@ -74,6 +72,8 @@ describe('local attachment service', () => { const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) const access = service.imageAccess(ref) + expect(access).toBeDefined() + if (access === undefined) throw new Error('expected a host-path mapping from fs-local') expect(access.readonlyPath).toBe(join( dshHome, 'attachments', diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index ad29f856ec..64cca74b7e 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -132,9 +132,12 @@ describe('local attachment store', () => { expect(second.attachmentId).toBe(first.attachmentId) expect(new Uint8Array(await readFile(object))).toEqual(PNG) if (process.platform !== 'win32') { - expect((await stat(object)).mode & 0o777).toBe(0o600) + expect((await stat(object)).mode & 0o777).toBe(0o400) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) } + await chmod(object, 0o600) + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + if (process.platform !== 'win32') expect((await stat(object)).mode & 0o777).toBe(0o400) await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json index 3ac8b3fcff..a5fff7a98a 100644 --- a/packages/attachment/attachment-local/tsconfig.json +++ b/packages/attachment/attachment-local/tsconfig.json @@ -6,6 +6,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../attachment" }, + { "path": "../../fs/fs" }, { "path": "../../util/home-paths" }, { "path": "../../runtime-diagnostics/invariants" } ] diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index e10ce2d214..f72b0b2052 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -113,6 +113,7 @@ export abstract class AttachmentStore extends Service { * Resolve provider-specific model-tool access without adding host facts to session history. * @param ref - durable normalized attachment reference. * @returns current-provider access facts, or undefined when this backend exposes no local path. + * @throws an AttachmentError when the durable reference is invalid. */ imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { void ref diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index fa75709ec2..97e549e84b 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -360,6 +360,7 @@ describe('E2BFileSystem identity, metadata, and reads', () => { const outside = await fs.resolve('/outside.ts') expect(fs.processPath(nested)).toBe('/workspace/nested/multibyte # file.ts') + expect(fs.processPathFromHostPath('/Users/alice/.dsh/attachments/object')).toBeUndefined() expect(fs.fileUrl(nested)).toBe('file:///workspace/nested/multibyte%20%23%20file.ts') expect(fs.contains(workspace, workspace)).toBe(true) expect(fs.contains(workspace, nested)).toBe(true) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 197ff6fadd..57d9ab16d6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -454,6 +454,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Resolve provider-specific model-tool access without adding host facts to session history.', parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }], returns: 'current-provider access facts, or undefined when this backend exposes no local path.', + throws: ['an AttachmentError when the durable reference is invalid.'], }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', @@ -769,6 +770,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'target', description: 'the resolved target whose process path is required.' }], returns: 'an absolute path in the backend\'s execution world.', }, + { + signature: 'processPathFromHostPath(hostPath: string): string | undefined', + description: 'Map an absolute path from the harness host into this filesystem\'s execution world when both paths identify the same file. The base provider exposes no mapping; host-backed or explicitly shared backends override it.', + parameters: [{ name: 'hostPath', description: 'absolute path in the harness host filesystem.' }], + returns: 'the process path for the same file, or undefined when this execution world cannot read that host file.', + }, { signature: 'abstract fileUrl(target: FsTarget): string', description: 'Return the canonical `file:` URI for a target in this filesystem\'s execution world. Backends own URI encoding because the host platform may differ from the execution platform.', diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 68d3340327..02d7e4b704 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-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/fs/fs-local/README.md -README.md: 4d5c42945b86ccdc8b041d9d7f99a067ab9a37f5 -README.zh.md: 51f417f73294b7497563fece168e5a5d44b73421 +README.md: ae6d2f582abfdbdfcf922899ffe485691167c97e +README.zh.md: 161e4ee9e5b3dbd0f7efc64692e10dc522538034 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 4d5c42945b..ae6d2f582a 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## Behavior - **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path. -- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`. +- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `processPathFromHostPath` accepts absolute host paths because this backend shares the host filesystem, `fileUrl` encodes target paths through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`. - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing. - **`readBytes`** — raw whole-file bytes with no decoding or binary rejection (the `read_image` tool validates content through the attachment service). The required byte cap short-circuits on the stat size before any content I/O; the subsequent stream reads at most one byte beyond the cap, so a file growing after stat still fails `FS_TOO_LARGE` without unbounded buffering. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 51f417f732..161e4ee9e5 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## 行为 - **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。 -- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。 +- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径。由于该后端共享宿主文件系统,`processPathFromHostPath` 接受绝对宿主路径。`fileUrl` 通过 Node 的平台感知 URL 转换对目标路径编码。`contains` 使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。 - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此异步探测进行期间发生的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以自行限制保留量。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。 - **`readBytes`**:按原始字节读取整个文件,不做解码或二进制拒绝(`read_image` 工具通过附件服务校验内容)。必填的字节上限在任何内容 I/O 之前先按 stat 大小短路;随后的流最多多读一个字节,因此 stat 之后增长的文件仍会以 `FS_TOO_LARGE` 失败,不会无界缓冲。 diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 661ef236b8..7c50532a0e 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -114,6 +114,10 @@ export class LocalFileSystem extends FileSystem { return String(target.targetKey) } + override processPathFromHostPath(hostPath: string): string | undefined { + return isAbsolute(hostPath) ? resolve(hostPath) : undefined + } + override fileUrl(target: FsTarget): string { return pathToFileURL(this.processPath(target)).href } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 1977f438b9..b791b424fb 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -59,6 +59,12 @@ describe('registration', () => { await bareFiber.dispose() }) + it('maps only absolute host paths into its process path namespace', () => { + const path = join(dir, 'image.png') + expect(fs.processPathFromHostPath(path)).toBe(path) + expect(fs.processPathFromHostPath('image.png')).toBeUndefined() + }) + it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => { const maxDiffBasisBytes = Math.min( bufferConstants.MAX_LENGTH, diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 5736cd6c81..c001d6a107 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/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/fs/fs/README.md -README.md: 7bde7d4d64005a6bdd0f0ac974bf45e5450e4207 -README.zh.md: c4366dc1805938a7020310f4cf627b9072a75cd5 +README.md: b6255a385daf9185ccbfb45e4d1896ad2e8a2c9e +README.zh.md: e932b3144430618e2ec7458491b26d43c517024f diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 7bde7d4d64..b6255a385d 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. +The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, map shared host files, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. This package owns the Service Definition and provider contract layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -17,12 +17,13 @@ This package owns the Service Definition and provider contract layer of the four ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements twelve primitives. +A backend subclasses `FileSystem` and exposes thirteen primitives. | Member | Semantics | |---|---| | `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default), while `opts.signal` aborts a backend round-trip. Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | | `processPath(target)` | Return the canonical absolute path that a subprocess in this provider's execution world can open. This is intentionally distinct from opaque `targetKey`. | +| `processPathFromHostPath(hostPath)` | Return this execution world's process path for the same absolute host file when the backend shares it. The base implementation returns `undefined`; host-backed or explicitly mapped backends override it. | | `fileUrl(target)` | Return the canonical `file:` URI in the execution world's platform syntax. The backend, not the host process, owns encoding. | | `contains(parent, child)` | Test canonical identity/descendant containment without exposing or parsing target keys. Both targets come from this provider. | | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | @@ -61,6 +62,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **Text-only mutations by contract** — text reads and both mutations reject binary/non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` is the one raw-byte primitive, and binary-safe mutations remain a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md). -- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). +- **Thirteen primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). - **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)). - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index c4366dc180..e932b31444 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、有界读取原始字节、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 +**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、映射共享的宿主文件、检查包含关系、完整或流式读取文本、有界读取原始字节、检查或列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 本包拥有四层文件系统栈中的 Service Definition 和提供方约定层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md)): @@ -17,12 +17,13 @@ ## 服务 API(`ctx.fs`) -后端继承 `FileSystem` 并实现十二个原语。 +后端继承 `FileSystem` 并公开十三个原语。 | 成员 | 语义 | |---|---| | `resolve(path, opts?)` | 把路径解析为稳定的 `FsTarget`(不透明 `targetKey`、`displayPath`)。`opts.cwd` 是相对 `path` 解析所依据的基准(调用方提供其会话工作区;绝对路径忽略该值;省略时使用后端默认值),`opts.signal` 则中止后端往返。该方法是异步的,因为远程后端可能需要 I/O。经不同路径到达的同一文件必须产生相同 `targetKey`。 | | `processPath(target)` | 返回该提供方执行世界中的子进程可以打开的规范化绝对路径。该路径有意与不透明的 `targetKey` 分离。 | +| `processPathFromHostPath(hostPath)` | 当后端共享同一个宿主文件时,返回该文件在当前执行世界中的进程路径。基类返回 `undefined`,宿主后端或显式映射宿主文件的后端负责覆盖。 | | `fileUrl(target)` | 返回采用执行世界平台语法的规范化 `file:` URI。编码由后端而非宿主进程负责。 | | `contains(parent, child)` | 在不公开或解析目标 key 的情况下,检查规范化身份相等或后代包含关系。两个目标都来自该提供方。 | | `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version`、`type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 | @@ -61,6 +62,6 @@ ## 已知限制与延期工作 - **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 是唯一的原始字节原语,二进制安全的变更操作仍是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md)有意延期的工作。 -- **只有十二个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 +- **只有十三个原语**:没有删除、重命名或移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 - **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.zh.md))。 - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 8ecd03c714..e32890d732 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -125,6 +125,19 @@ export abstract class FileSystem extends Service { */ abstract processPath(target: FsTarget): string + /** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ + processPathFromHostPath(hostPath: string): string | undefined { + void hostPath + return undefined + } + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 121901dc95..6fbe1268af 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -88,6 +88,7 @@ describe('FileSystem provider seam', () => { await ctx.plugin(FakeFileSystem) const fs = ctx.fs as FakeFileSystem expect(fs.sandboxMode).toBeUndefined() + expect(fs.processPathFromHostPath('/host/file')).toBeUndefined() fs.files.set('a.txt', 'hi') const target = await fs.resolve('a.txt') expect((await fs.stat(target))?.type).toBe('file') diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 747e37ca4c..4f5864aa6d 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -195,6 +195,9 @@ export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] { const seen = new Set() return (models ?? DEFAULT_MODELS).map((model) => { + if (Object.hasOwn(model, 'imageDetail')) { + throw new Error('llm-deepseek: catalog model imageDetail is no longer supported; use imagePixelBudget') + } if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty') if (model.name !== undefined && model.name.length === 0) { throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9408f1595b..362a90dadb 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1895,6 +1895,20 @@ describe('plugin registration and config', () => { expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) }) + it('rejects the removed imageDetail model setting through schema and direct construction', async () => { + const legacyModel = { id: 'vision', inputModalities: ['image'], imageDetail: 'low' } as unknown as + LlmDeepSeek.DeepSeekCatalogModel + expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [legacyModel], + })).rejects.toThrow(/imageDetail is no longer supported/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) .toThrow(/maxTokens must be a positive integer/) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e763188fd0..eebeb12111 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1136,6 +1136,12 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths From fdf60301f2585dd497d239d6fb17b533ce9a0334 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 13:38:34 +0800 Subject: [PATCH 034/138] fix(system-prompt): stabilize workflow section order --- ...-20-config-hot-reload-resilience.i18n.yaml | 4 +-- ...2026-07-20-config-hot-reload-resilience.md | 2 +- ...6-07-20-config-hot-reload-resilience.zh.md | 2 +- ...system-prompt-section-order-ties.i18n.yaml | 6 ++++ ...-08-24-system-prompt-section-order-ties.md | 27 +++++++++++++++ ...-24-system-prompt-section-order-ties.zh.md | 27 +++++++++++++++ .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../both-mode-turn/system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../code-mode-turn/system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../lsp-definition/system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../pty-tools/system-prompt.expected.md | 4 +-- .../read-image/system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../system-prompt.1.expected.md | 4 +-- .../system-prompt.1.expected.md | 4 +-- .../session.1.jsonl | 2 +- .../session.2.jsonl | 2 +- .../system-prompt.1.expected.md | 4 +-- .../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 +- .../subagent-parallel/session.1.jsonl | 8 ++--- .../subagent-parallel/session.2.jsonl | 8 ++--- .../system-prompt.1.expected.md | 4 +-- .../text-turn/system-prompt.expected.md | 4 +-- .../web-fetch/system-prompt.expected.md | 4 +-- .../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/workflow/tool-ralph/src/index.ts | 2 +- packages/workflow/tool-workflow/src/index.ts | 2 +- 40 files changed, 164 insertions(+), 104 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml index 0d29db621d..0b59ec54b6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md -2026-07-20-config-hot-reload-resilience.md: 2b98ee73e1c7734da9c0ae6df5e3de1af6a5cbeb -2026-07-20-config-hot-reload-resilience.zh.md: 0a3652a7cac589b601d7fd30d0c713d4585be830 +2026-07-20-config-hot-reload-resilience.md: 6f8503c93bcbf55b1bd0543cf49e41cfe32fbe2f +2026-07-20-config-hot-reload-resilience.zh.md: 359a34a2252b2999f896245a4bd07f7da32d98f5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md index 2b98ee73e1..6f8503c93b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -38,4 +38,4 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met ## Testing -`packages/boot/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/boot/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections. +`packages/boot/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/boot/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, and `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md index 0a3652a7ca..359a34a225 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -38,4 +38,4 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方 ## Testing -`packages/boot/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/boot/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除;ACP(Agent Client Protocol)的 `pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序。 +`packages/boot/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/boot/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject;`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml new file mode 100644 index 0000000000..d53f6820ce --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.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-24-system-prompt-section-order-ties.md +2026-08-24-system-prompt-section-order-ties.md: 9aba5ee117a07b1d5abd6497232e61a48e55a639 +2026-08-24-system-prompt-section-order-ties.zh.md: 9736aa94f12a9ddf7242ef6d95b3c0204c4b62ed diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md new file mode 100644 index 0000000000..9aba5ee117 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md @@ -0,0 +1,27 @@ +# Agent Note: Equal-order system-prompt sections render in activation order + +Status: implemented + +English | [中文](2026-08-24-system-prompt-section-order-ties.zh.md) + +## Problem + +`SystemPromptRegistry` sorts sections by `order` with a stable sort, so equal orders render in plugin-activation order. `tool:cordis` and `tool:workflow` both declared `order: 115`, while their activation order varies between clean platform compositions. ACP and SDK snapshot replays could therefore assemble the same sections in a different order from their committed `system-prompt.expected.md` files. + +## Decision + +Give the three ordered sections distinct values: `tool:cordis` stays at 115, `tool:workflow` moves to 116, and `tool:ralph` moves from 116 to 117 so it remains after workflow. Prompt text and tool schemas remain unchanged. + +## Alternatives considered + +**Normalize section order in the snapshot harness.** Rejected because the runtime, request header, and model prompt would remain sensitive to activation timing while only the fixture comparison hid the difference. + +**Tie-break equal orders by section name in the registry.** Rejected because it would silently reorder every existing tie. Explicit orders keep each model-visible placement local to the contributing plugin. + +## Consequences + +The Cordis, workflow, and Ralph guidance has one platform-independent order. Prompt-section placements that require a stable relative position need distinct `order` values; stable sorting continues to preserve activation order for intentional ties. + +## Testing + +The keyless ACP and SDK snapshot replays pin the affected system-prompt order, and the full snapshot suite verifies the refreshed fixtures. diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md new file mode 100644 index 0000000000..9736aa94f1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 等序系统提示词分段按激活顺序渲染 + +Status: implemented + +[English](2026-08-24-system-prompt-section-order-ties.md) | 中文 + +## Problem + +`SystemPromptRegistry` 使用稳定排序按 `order` 排列分段,因此相同 order 的分段会按插件激活顺序渲染。`tool:cordis` 与 `tool:workflow` 都声明了 `order: 115`,但两者在不同平台的全新组合中激活顺序不同。因此,ACP(Agent Client Protocol)与 SDK 的快照回放可能把相同分段组装成不同于已提交 `system-prompt.expected.md` 文件的顺序。 + +## Decision + +为这三个有序分段指定互不相同的 order:`tool:cordis` 保持 115,`tool:workflow` 移到 116,`tool:ralph` 从 116 移到 117,从而继续排在 workflow 之后。提示词文本与工具 schema 保持不变。 + +## Alternatives considered + +**在快照 harness 中规范化分段顺序。** 已否决,因为运行时、请求标头和模型提示词仍然受激活时序影响,只有 fixture 比较会隐藏差异。 + +**在注册表中用分段名称打破并列。** 已否决,因为这会静默重排每一组现有并列。显式 order 让每个模型可见位置都由贡献该分段的插件就地决定。 + +## Consequences + +Cordis、workflow 与 Ralph 指引具有不依赖平台的唯一顺序。需要稳定相对位置的提示词分段必须使用互不相同的 `order`;对于有意设置的并列,稳定排序仍会保留激活顺序。 + +## Testing + +无密钥 ACP 与 SDK 快照回放会固定受影响的系统提示词顺序,完整快照套件则验证刷新的 fixture。 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 01ba355631..470217b161 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"e3c23441-606f-4e7a-8338-b434c0d04a4e"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"3d72b77d-f8f9-4e5b-b918-6ef0d13c35a2"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 73958c517c..a62cd266ab 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"5d128e81-c7c2-4cd0-ad1c-7409b33650fc"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"2c50b91f-f7ee-4aad-afc5-b08732633604"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 1743643d95..c20ff88a34 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 @@ -131,10 +131,10 @@ return { 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use 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. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: diff --git a/examples/acp-agent/tests/snapshots/agent-instructions/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/agent-instructions/system-prompt.expected.md index 7150bf2e6b..4517bb46cb 100644 --- a/examples/acp-agent/tests/snapshots/agent-instructions/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/agent-instructions/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 37df6287ed..0c8262c90d 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -25,10 +25,10 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use 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. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index daf622df60..4913a69d8c 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -27,10 +27,10 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use 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. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: 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 6894f13fb6..79e1d537f8 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 @@ -27,10 +27,10 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use 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. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md index 9b4698844c..95936c6f15 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md @@ -18,6 +18,6 @@ Use the web_search tool to discover current information on the web. The required 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index b906b6f3c8..fee88385ae 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -27,6 +27,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md index 545e903230..f37a2d570a 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. diff --git a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md index 06b614520c..9939935412 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md @@ -27,6 +27,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. diff --git a/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md index a0d3386eaa..dd0ebca77d 100644 --- a/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md index 800356dccc..5166d0236f 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md @@ -27,6 +27,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. 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 index b198b48a12..2da38d57c5 100644 --- 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 @@ -25,8 +25,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use 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/system-prompt.1.expected.md b/examples/acp-agent/tests/snapshots/subagent-continuable/system-prompt.1.expected.md index b198b48a12..2da38d57c5 100644 --- 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 @@ -25,8 +25,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use 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-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index b64494a028..2e7f6f7c9a 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 @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"72272791-eefd-48f8-94da-02b132ae9d2a"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"37b2a8d1-90f2-4aad-9e35-737559cdaaa2"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Call subagent once. Ask that","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 3e3dfa357b..3a3d440f2d 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 @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"c54120cc-6a7f-41f6-a71d-42b4805fa2ca"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"776d4567-6a92-45a6-8a74-8d376af180e5"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Attempt one subagent call beyond","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 index b198b48a12..2da38d57c5 100644 --- 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 @@ -25,8 +25,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use 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-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index b64a6cfdfe..474ccadf60 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"f216ca0e-6dcc-4ab3-9cdb-fe38d3dacca2"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"c3bc9e3e-bf25-4169-ad2c-bda0183fad84"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 e7de6e1e7d..8ff90306d3 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -31,7 +31,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","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","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":"ac4f4d97-639d-4ad0-a513-219a58355531"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"95dee7dd-6ac8-4695-b3f0-745cb724dfbd"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," 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 e1b186cd1f..6cea54fb30 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"47cdc6a0-a8c8-4842-964a-ad4bc97dc76a"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"967aa8c4-7ad8-4973-966a-a6fcf8ba00dd"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 f8cb2e5924..2888c8f591 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"f9132345-93c9-40c0-b489-5916bbca96bc"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"c52ae99e-3f24-4803-a002-56564ed59845"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl index 14b644dc33..0cb0df4130 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl @@ -2,19 +2,19 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fadafbc9-263b-4169-82c6-a39868629377"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c0a22dc3-7ed8-401f-9d27-daa216035e79"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fadafbc9-263b-4169-82c6-a39868629377"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"1d6d2982-78f7-49b9-b32d-0eb465d672b1"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c0a22dc3-7ed8-401f-9d27-daa216035e79"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"f995fac5-e34b-468d-9162-d64cbac11ab8"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9ccb6b64-4dfb-47a2-9967-13ab05483998"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2814fde1-ee68-4d3c-a14f-7b9ea936384c"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl index 5f298c76c5..86ae5204d3 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl @@ -2,19 +2,19 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"dc34a17f-fb30-4afe-a11f-a0d8a1d51658"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6bbb164f-99ee-4b7f-a0bd-c1a35b02a076"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"dc34a17f-fb30-4afe-a11f-a0d8a1d51658"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"12a26f3d-f11e-4de4-8bed-d997590d21e0"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6bbb164f-99ee-4b7f-a0bd-c1a35b02a076"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"7cbd146e-97ac-49f2-979a-4221d9277ec8"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d368f9a5-7d0a-46f0-a7d8-10e1fafa1e74"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c40ec82f-7cb8-4885-be04-ba85bff6e2a2"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} 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 index b198b48a12..2da38d57c5 100644 --- 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 @@ -25,8 +25,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use 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/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 975b5a7baf..b1d7332947 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. diff --git a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md index b70cc036d4..ab93903173 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use 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. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use 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. 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 1287de6339..b1e3b0e457 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","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":"c66e310e-2597-4d01-85c8-2d70a9d831c0"}]}} +{"type":"agent/inbox/spliced","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":"f9b76611-65ac-4d61-ba91-cb003d56670b"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c66e310e-2597-4d01-85c8-2d70a9d831c0"},"surfaceOp":"append"} -{"type":"user/message","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":"fd0a0587-8df4-46b2-809a-317346a4c0f4"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f9b76611-65ac-4d61-ba91-cb003d56670b"},"surfaceOp":"append"} +{"type":"user/message","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":"09b80e2e-0a74-48b9-88c3-03e1d859fb5d"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"02dd8a61-a39a-46d0-8f6f-457533271cae"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","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":"f018f8fa-7a81-46a3-93a0-a30705a7785f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","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 1f18e866a9..6be8abb48f 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","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":"8b3cd23c-82f1-4903-8a3f-b9082059b40c"}]}} +{"type":"agent/inbox/spliced","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":"ebb3764d-0742-4054-a421-69d479b90021"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8b3cd23c-82f1-4903-8a3f-b9082059b40c"},"surfaceOp":"append"} -{"type":"user/message","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":"823e5037-9e96-4ef5-8c5b-cbe73b993ee2"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebb3764d-0742-4054-a421-69d479b90021"},"surfaceOp":"append"} +{"type":"user/message","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":"8390dc9d-26c3-439b-9f60-ac263964f0d3"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"cd78f077-1fad-4cdc-ab56-09d39d9095cd"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","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":"80f282de-e531-41cc-94d9-d6896f6a2800"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","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 c49d9b2745..dbe2d47352 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","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"8a0ac233-283c-4eb4-8bbd-5c50b7e99afe"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"be704496-d340-4e19-8506-6cf81feb739a"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"8a0ac233-283c-4eb4-8bbd-5c50b7e99afe"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"be704496-d340-4e19-8506-6cf81feb739a"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e0351488-7bca-48d6-b7af-87d533858f47"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"28ed15b0-0b10-4cb7-bdb8-17af0c101f9a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"9fd9ba62-1b6d-4bb7-98c6-97815e983026"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"f037d5e8-3ef3-4c76-8bd9-e823543d16c2"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,13 +22,13 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6f6c3cc3-350b-4afb-a3d1-8f91b9494628"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8a02e55c-d6e7-4b63-9674-856c71c385a1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"},"isError":false,"content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}]}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"},"isError":false,"content":[{"type":"text","text":"{\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n}"}]}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"40a17cbf-a853-4813-bbb5-7970cfbc7010"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"0d0af8e0-841c-416f-9be6-bcbed9406669"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -36,9 +36,9 @@ {"type":"assistant/chunk","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.\",\"run_in_background\":false}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bdea090-4a9a-451f-bb21-459af50472fa"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"assistant/message","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.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1775d38-b17e-4e98-8dff-700d604cddbf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} {"type":"tool/call","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.\",\"run_in_background\":false}"}} -{"type":"tool/result","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":"c978c208-8ebd-4dd6-b997-606ca7de787e"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","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":"a9ae23a0-f3b1-432e-ade5-0a25aec04838"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -46,13 +46,13 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"e55b2b2e-497c-45d2-8115-16f317ae573f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"assistant/message","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":"3be25390-4ab3-4d98-a7b3-6b7df9c89f47"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} {"type":"tool/call","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-workflow/run-start","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","name":"advanced-headless-snapshot"}} {"type":"tool-workflow/agent-start","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} {"type":"tool-workflow/agent-end","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"outcome":"completed"}} {"type":"tool-workflow/run-end","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","stopReason":"completed"}} -{"type":"tool/result","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":"f85f58fc-8d7e-4c9f-a0fe-caff480a9fec"}},"sourceEventSeqs":[48],"surfaceOp":"append"} +{"type":"tool/result","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":"4d88debc-419d-4bea-ace5-e4626475cf83"}},"sourceEventSeqs":[48],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -60,9 +60,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"68bd993f-a0bd-4ea8-ad16-6b1a19e09bd3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"00552a01-9bbc-4fc0-82b5-a00140d5b12a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"2d90e3b5-2a4c-4408-a1a0-3d009786a07b"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"af8f561e-1a9a-48e9-a94b-80e361fe5142"}},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -70,6 +70,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"f3823367-2e25-43d2-a129-70dc492b2a90"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} +{"type":"assistant/message","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":"4556caeb-f947-40b9-a3c4-98e0f48b2edc"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"turn/end","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 575bbc5dfa..5963280e2c 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","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"10eb2388-2d40-4564-af27-e7a5419fc14e"}]}} +{"type":"agent/inbox/spliced","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"7e4059c6-45e3-4067-8966-b2d402b97879"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"10eb2388-2d40-4564-af27-e7a5419fc14e"},"surfaceOp":"append"} +{"type":"user/message","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"7e4059c6-45e3-4067-8966-b2d402b97879"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","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","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","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"a71b2cfd-c18f-4a1b-82f6-e89fb371a87e"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","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":"d1e07317-e842-4a27-bd7d-b679adebb42a"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","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":"c9e68608-2dff-44bc-a344-b01006272378"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","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":"ed26c0aa-4a0a-49e3-88b4-795f48e02e45"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","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":"compaction/start","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"compaction/summary","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":266,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","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":"3668b957-07a2-4cb7-96b1-98a23ac8cdb8"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"user/message","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":"6f0985ae-7ff0-4923-80ab-0548d2cf466f"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} {"type":"compaction/end","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"bcbfd4ff-60e5-4634-ae39-4de3708a8abc"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","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":"e5a0b0a1-8f90-450c-b994-27eb0d084be0"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","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 6e5148b4de..b285d23511 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","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":"d35cdacd-b5e6-4968-b7a3-5ec48f403ef7"}]}} +{"type":"agent/inbox/spliced","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":"d20ef69c-ac94-4aed-9c4a-821a6ebff160"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","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":"d35cdacd-b5e6-4968-b7a3-5ec48f403ef7"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"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":"053af702-9950-4860-913a-3c7e45a54f9d"},"surfaceOp":"append"} +{"type":"user/message","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":"d20ef69c-ac94-4aed-9c4a-821a6ebff160"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"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":"8731a232-5605-410b-9dd4-f7ae7b1a3f04"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 shell/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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","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":"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 job id for job_output/job_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 job id immediately; collect with job_output or stop with job_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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 shell/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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","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":"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 job id for job_output/job_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 job id immediately; collect with job_output or stop with job_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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","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","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","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"911213f8-acce-47be-a4f2-9d72ef55d83a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","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":"d642809d-44ee-42cb-867d-63acb3d88b7a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","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":"2da21b47-7fb3-444c-99a6-2c21743731ee"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","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":"f5d9b371-8400-4993-9cac-f61fcc1aab9b"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"cf12d7ee-322a-4057-97b0-98d828a96f1a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","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":"bf54676e-46ad-41b6-8938-c5b5cf9429ea"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","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":"d1ffb2dc-6a33-4c9e-aeb9-b87bf6da8617"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","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":"1ea99fa1-54bd-45cd-8935-041298013a91"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"b9b99ae9-4685-4ee5-b951-fbe58f84c4e3"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","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":"cb394133-a39a-4c7c-bba0-f51a06a1ddd9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","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":"d0d7331d-0d54-457a-9f7b-beb22abd34e6"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","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":"116cdcf0-42dc-4220-8f01-38ed1153f903"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"9bb8ec3d-6e6f-44a2-8957-8f2d855f4834"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","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":"b7216958-bc3a-4631-bf30-31db4f96b5f4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","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":"16fc1ca2-0eca-42d7-85d0-31794424c260"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","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":"d950a25e-05ca-4eec-aaf8-9937b8fe4f48"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,9 +53,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"4ebdc957-0369-4bbb-a5a5-4d2ef8ac3493"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","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":"6dae8392-a011-47e5-84d8-d9e4c3c19d0f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","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":"55a3e2cc-dbc5-44bf-a824-e3bbc8568cd5"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","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":"fb3d7081-eba7-4169-a2fe-e05ecae357ac"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -63,9 +63,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"6f956d23-5437-4a75-93a9-3abacd378e07"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","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":"b4305101-fbda-4a57-ac80-1c6cdae233f9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","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":"be12d914-6fe1-4c1e-8262-65b2aa9c20e5"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","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":"0097f12a-a04e-4042-b47a-4a1535708049"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -73,6 +73,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9cca9680-5795-47d8-8edc-f6d44bcaa1ef"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b75bf882-4d9d-4f92-9125-0cdc18f76f25"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts index 3e3e1b5943..d92d7251e2 100644 --- a/packages/workflow/tool-ralph/src/index.ts +++ b/packages/workflow/tool-ralph/src/index.ts @@ -406,7 +406,7 @@ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:ralph', - order: 116, + order: 117, text: '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.', }) ctx.tools.register(defineTool({ diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 446c70ce03..899c6f8ab7 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -211,7 +211,7 @@ export function apply(ctx: Context, config: Config): void { // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ name: `tool:${toolName}`, - order: 115, + order: 116, text: `Use the ${toolName} 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.`, }) ctx.tools.register(defineTool({ From 25428f8e089a76cccf63ce4877e8827e55ba79b7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 14:12:36 +0800 Subject: [PATCH 035/138] fix(system-prompt): preserve downstream section order --- ...system-prompt-section-order-ties.i18n.yaml | 4 +-- ...-08-24-system-prompt-section-order-ties.md | 6 ++-- ...-24-system-prompt-section-order-ties.zh.md | 6 ++-- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../both-mode-turn/system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../code-mode-turn/system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../lsp-definition/system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../pty-tools/system-prompt.expected.md | 4 +-- .../read-image/system-prompt.expected.md | 4 +-- .../system-prompt.expected.md | 4 +-- .../system-prompt.1.expected.md | 4 +-- .../system-prompt.1.expected.md | 4 +-- .../session.1.jsonl | 2 +- .../session.2.jsonl | 2 +- .../system-prompt.1.expected.md | 4 +-- .../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 +- .../subagent-parallel/session.1.jsonl | 8 ++--- .../subagent-parallel/session.2.jsonl | 8 ++--- .../system-prompt.1.expected.md | 4 +-- .../text-turn/system-prompt.expected.md | 4 +-- .../web-fetch/system-prompt.expected.md | 4 +-- .../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/workflow/tool-ralph/src/index.ts | 2 +- packages/workflow/tool-workflow/src/index.ts | 2 +- 37 files changed, 108 insertions(+), 108 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml index d53f6820ce..17d2684a6c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.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-24-system-prompt-section-order-ties.md -2026-08-24-system-prompt-section-order-ties.md: 9aba5ee117a07b1d5abd6497232e61a48e55a639 -2026-08-24-system-prompt-section-order-ties.zh.md: 9736aa94f12a9ddf7242ef6d95b3c0204c4b62ed +2026-08-24-system-prompt-section-order-ties.md: 673c3b3cd668115ead9f9b5478c2bc432b78f930 +2026-08-24-system-prompt-section-order-ties.zh.md: 96a6843a0db48e52a2132ad9f8caf6243dfbbcd2 diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md index 9aba5ee117..673c3b3cd6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md +++ b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md @@ -10,7 +10,7 @@ English | [中文](2026-08-24-system-prompt-section-order-ties.zh.md) ## Decision -Give the three ordered sections distinct values: `tool:cordis` stays at 115, `tool:workflow` moves to 116, and `tool:ralph` moves from 116 to 117 so it remains after workflow. Prompt text and tool schemas remain unchanged. +Give the affected sequence distinct values without changing its established relative order: `tool:cordis` stays at 115, `tool:workflow` uses 115.5, `tool:ralph` stays at 116, continuable subagent guidance stays at 116.5, and child-report guidance stays at 117. Prompt text and tool schemas remain unchanged. ## Alternatives considered @@ -20,8 +20,8 @@ Give the three ordered sections distinct values: `tool:cordis` stays at 115, `to ## Consequences -The Cordis, workflow, and Ralph guidance has one platform-independent order. Prompt-section placements that require a stable relative position need distinct `order` values; stable sorting continues to preserve activation order for intentional ties. +The Cordis and workflow guidance has a platform-independent order while Ralph remains before continuable subagent and child-report guidance. Prompt-section placements that require a stable relative position need distinct `order` values; other equal-order sections retain activation-order semantics and are outside this decision. ## Testing -The keyless ACP and SDK snapshot replays pin the affected system-prompt order, and the full snapshot suite verifies the refreshed fixtures. +The keyless ACP and SDK snapshot replays pin Cordis before workflow and preserve the workflow, Ralph, continuable-subagent, and child-report sequence. The full snapshot suite verifies the refreshed fixtures. diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md index 9736aa94f1..96a6843a0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -为这三个有序分段指定互不相同的 order:`tool:cordis` 保持 115,`tool:workflow` 移到 116,`tool:ralph` 从 116 移到 117,从而继续排在 workflow 之后。提示词文本与工具 schema 保持不变。 +在不改变既有相对顺序的前提下,为受影响的分段序列指定互不相同的 order:`tool:cordis` 保持 115,`tool:workflow` 使用 115.5,`tool:ralph` 保持 116,可继续运行的子代理指引保持 116.5,子代理报告指引保持 117。提示词文本与工具 schema 保持不变。 ## Alternatives considered @@ -20,8 +20,8 @@ Status: implemented ## Consequences -Cordis、workflow 与 Ralph 指引具有不依赖平台的唯一顺序。需要稳定相对位置的提示词分段必须使用互不相同的 `order`;对于有意设置的并列,稳定排序仍会保留激活顺序。 +Cordis 与 workflow 指引具有不依赖平台的顺序,同时 Ralph 仍排在可继续运行的子代理指引和子代理报告指引之前。需要稳定相对位置的提示词分段必须使用互不相同的 `order`;其他等序分段仍采用激活顺序,不属于本决策的范围。 ## Testing -无密钥 ACP 与 SDK 快照回放会固定受影响的系统提示词顺序,完整快照套件则验证刷新的 fixture。 +无密钥 ACP 与 SDK 快照回放会固定 Cordis 排在 workflow 之前,并保留 workflow、Ralph、可继续运行的子代理和子代理报告指引的顺序。完整快照套件验证刷新的 fixture。 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 470217b161..f273ce4d5f 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"3d72b77d-f8f9-4e5b-b918-6ef0d13c35a2"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"fa9584c0-dfe6-4843-b9f0-41eaabf98ec0"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 a62cd266ab..052b62c82f 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"2c50b91f-f7ee-4aad-afc5-b08732633604"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"383e5a64-a889-40e5-a0bd-6504024c74b2"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 c20ff88a34..1743643d95 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 @@ -131,10 +131,10 @@ return { Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: diff --git a/examples/acp-agent/tests/snapshots/agent-instructions/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/agent-instructions/system-prompt.expected.md index 4517bb46cb..7150bf2e6b 100644 --- a/examples/acp-agent/tests/snapshots/agent-instructions/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/agent-instructions/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 0c8262c90d..37df6287ed 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -25,10 +25,10 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index 4913a69d8c..daf622df60 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -27,10 +27,10 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: 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 79e1d537f8..6894f13fb6 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 @@ -27,10 +27,10 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md index 95936c6f15..9b4698844c 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/system-prompt.expected.md @@ -18,6 +18,6 @@ Use the web_search tool to discover current information on the web. The required Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index fee88385ae..b906b6f3c8 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -27,6 +27,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md index f37a2d570a..545e903230 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md index 9939935412..06b614520c 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md @@ -27,6 +27,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md index dd0ebca77d..a0d3386eaa 100644 --- a/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md index 5166d0236f..800356dccc 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md @@ -27,6 +27,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. 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 index 2da38d57c5..b198b48a12 100644 --- 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 @@ -25,8 +25,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + 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/system-prompt.1.expected.md b/examples/acp-agent/tests/snapshots/subagent-continuable/system-prompt.1.expected.md index 2da38d57c5..b198b48a12 100644 --- 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 @@ -25,8 +25,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + 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-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index 2e7f6f7c9a..7c6db15da2 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 @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"37b2a8d1-90f2-4aad-9e35-737559cdaaa2"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"cb8e46e2-6f46-4f4c-ab97-96b925df62b4"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Call subagent once. Ask that","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 3a3d440f2d..1b65465994 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 @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"776d4567-6a92-45a6-8a74-8d376af180e5"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"45e7b5cb-cd03-4a92-9560-09317f5c3e15"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Attempt one subagent call beyond","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 index 2da38d57c5..b198b48a12 100644 --- 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 @@ -25,8 +25,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + 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-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index 474ccadf60..df79bc0066 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"c3bc9e3e-bf25-4169-ad2c-bda0183fad84"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"41fe3733-7671-416c-affa-a71500ef0936"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 8ff90306d3..d55d6f34e8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -31,7 +31,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","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","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":"95dee7dd-6ac8-4695-b3f0-745cb724dfbd"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"57e307d9-3800-41f4-80b3-511476d84745"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," 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 6cea54fb30..750bc35d2a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"967aa8c4-7ad8-4973-966a-a6fcf8ba00dd"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"0018c379-5d56-4594-b980-1a6009ef8767"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 2888c8f591..05ded9867f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -8,7 +8,7 @@ {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"c52ae99e-3f24-4803-a002-56564ed59845"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"3d741097-e063-4fc1-bba0-1f7f1dc4fc45"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl index 0cb0df4130..734566070e 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl @@ -2,19 +2,19 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c0a22dc3-7ed8-401f-9d27-daa216035e79"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9e5dc791-bb17-4354-bbdc-41b8a4ad6b17"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c0a22dc3-7ed8-401f-9d27-daa216035e79"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"f995fac5-e34b-468d-9162-d64cbac11ab8"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9e5dc791-bb17-4354-bbdc-41b8a4ad6b17"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"2e5f49b2-e459-47dd-b347-9c5b68c244de"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2814fde1-ee68-4d3c-a14f-7b9ea936384c"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"df5873d9-8eba-49b9-9634-0231b3945982"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl index 86ae5204d3..23b85f753c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl @@ -2,19 +2,19 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6bbb164f-99ee-4b7f-a0bd-c1a35b02a076"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"0fb40673-0440-4ed3-ba2f-07eac9586e05"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6bbb164f-99ee-4b7f-a0bd-c1a35b02a076"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"7cbd146e-97ac-49f2-979a-4221d9277ec8"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"0fb40673-0440-4ed3-ba2f-07eac9586e05"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"8394d04c-a143-4b26-867f-3e4e04a94116"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c40ec82f-7cb8-4885-be04-ba85bff6e2a2"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c31a0818-10e0-471d-afe1-8ff2fee62eda"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} 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 index 2da38d57c5..b198b48a12 100644 --- 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 @@ -25,8 +25,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + 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/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index b1d7332947..975b5a7baf 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md index ab93903173..b70cc036d4 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/web-fetch/system-prompt.expected.md @@ -25,6 +25,6 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use 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. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. 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 b1e3b0e457..100f729a7e 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","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":"f9b76611-65ac-4d61-ba91-cb003d56670b"}]}} +{"type":"agent/inbox/spliced","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":"94e03719-effb-4b99-bba9-621d1798a933"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f9b76611-65ac-4d61-ba91-cb003d56670b"},"surfaceOp":"append"} -{"type":"user/message","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":"09b80e2e-0a74-48b9-88c3-03e1d859fb5d"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"94e03719-effb-4b99-bba9-621d1798a933"},"surfaceOp":"append"} +{"type":"user/message","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":"3f567eec-b0d3-4692-9e89-bd2a53623b69"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"f018f8fa-7a81-46a3-93a0-a30705a7785f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","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":"4c10875e-5b61-4fbf-81e4-6306ab793f77"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","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 6be8abb48f..dd372221a9 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","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":"ebb3764d-0742-4054-a421-69d479b90021"}]}} +{"type":"agent/inbox/spliced","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":"335b85c1-27e6-4047-b7d6-24ccc388ea13"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebb3764d-0742-4054-a421-69d479b90021"},"surfaceOp":"append"} -{"type":"user/message","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":"8390dc9d-26c3-439b-9f60-ac263964f0d3"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"335b85c1-27e6-4047-b7d6-24ccc388ea13"},"surfaceOp":"append"} +{"type":"user/message","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":"1ae21e84-a121-420a-a80a-986bcb9bfd0a"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"80f282de-e531-41cc-94d9-d6896f6a2800"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","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":"b72bbf47-34ca-4fa1-bb18-37d0fb6150be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","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 dbe2d47352..933d98387f 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","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"be704496-d340-4e19-8506-6cf81feb739a"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"587f027c-803f-4141-b6f9-820b6b1379ea"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"be704496-d340-4e19-8506-6cf81feb739a"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"587f027c-803f-4141-b6f9-820b6b1379ea"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"28ed15b0-0b10-4cb7-bdb8-17af0c101f9a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9360e8c1-8782-489a-abe7-7b2900b3e43e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"f037d5e8-3ef3-4c76-8bd9-e823543d16c2"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"3cccd752-2d82-4e76-8aeb-65f8181f5d67"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,13 +22,13 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8a02e55c-d6e7-4b63-9674-856c71c385a1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f57879b3-f552-4209-9d7b-9b8eb14311b3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"},"isError":false,"content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}]}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"},"isError":false,"content":[{"type":"text","text":"{\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n}"}]}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"0d0af8e0-841c-416f-9be6-bcbed9406669"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"be73aaef-5172-4350-b03b-2fb5390f48ab"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -36,9 +36,9 @@ {"type":"assistant/chunk","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.\",\"run_in_background\":false}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1775d38-b17e-4e98-8dff-700d604cddbf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"assistant/message","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.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c506169d-8d5c-418d-a356-5824b0959032"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} {"type":"tool/call","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.\",\"run_in_background\":false}"}} -{"type":"tool/result","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":"a9ae23a0-f3b1-432e-ade5-0a25aec04838"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","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":"d057d856-694b-483c-a28a-bcbc4e8d829f"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -46,13 +46,13 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"3be25390-4ab3-4d98-a7b3-6b7df9c89f47"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"assistant/message","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":"5dc26fbe-bb01-42a5-beb9-1188ac9338d1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} {"type":"tool/call","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-workflow/run-start","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","name":"advanced-headless-snapshot"}} {"type":"tool-workflow/agent-start","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} {"type":"tool-workflow/agent-end","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"outcome":"completed"}} {"type":"tool-workflow/run-end","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","stopReason":"completed"}} -{"type":"tool/result","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":"4d88debc-419d-4bea-ace5-e4626475cf83"}},"sourceEventSeqs":[48],"surfaceOp":"append"} +{"type":"tool/result","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":"d999a81b-5c7d-4103-a087-ae8c2ddb0ed5"}},"sourceEventSeqs":[48],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -60,9 +60,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"00552a01-9bbc-4fc0-82b5-a00140d5b12a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"05fe05dc-6412-4d89-a285-c0134ac6c0ac"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"af8f561e-1a9a-48e9-a94b-80e361fe5142"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"da99fd98-3e31-4438-82ad-8cd5e8629181"}},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -70,6 +70,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"4556caeb-f947-40b9-a3c4-98e0f48b2edc"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} +{"type":"assistant/message","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":"ee6bf425-ed0f-42cd-863d-aa0ee6b312dd"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"turn/end","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 5963280e2c..d5894130a2 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","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"7e4059c6-45e3-4067-8966-b2d402b97879"}]}} +{"type":"agent/inbox/spliced","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"37b5d5fe-6796-4292-8c33-d60bdee54e72"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"7e4059c6-45e3-4067-8966-b2d402b97879"},"surfaceOp":"append"} +{"type":"user/message","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"37b5d5fe-6796-4292-8c33-d60bdee54e72"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","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","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","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"d1e07317-e842-4a27-bd7d-b679adebb42a"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","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":"4a9531dc-6e00-4321-878d-120fc32d6f50"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","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":"ed26c0aa-4a0a-49e3-88b4-795f48e02e45"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","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":"79ad618b-1f8e-4469-bf55-bf1d4e451520"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","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":"compaction/start","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"compaction/summary","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":266,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","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":"6f0985ae-7ff0-4923-80ab-0548d2cf466f"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"user/message","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":"4c8dec06-da41-4588-b99a-970997d3ec09"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} {"type":"compaction/end","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"e5a0b0a1-8f90-450c-b994-27eb0d084be0"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","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":"bcf13f76-b43e-4d0f-9a1c-1449f83ca779"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","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 b285d23511..a598ac7ad9 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","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":"d20ef69c-ac94-4aed-9c4a-821a6ebff160"}]}} +{"type":"agent/inbox/spliced","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":"73304b49-3a7c-4d92-ba69-3f34f4ebf101"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","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":"d20ef69c-ac94-4aed-9c4a-821a6ebff160"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"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":"8731a232-5605-410b-9dd4-f7ae7b1a3f04"},"surfaceOp":"append"} +{"type":"user/message","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":"73304b49-3a7c-4d92-ba69-3f34f4ebf101"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"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":"f411ee08-42d5-41bc-99b5-a33e0070f3c7"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 shell/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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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 subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","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":"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 job id for job_output/job_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 job id immediately; collect with job_output or stop with job_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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 shell/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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","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":"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 job id for job_output/job_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 job id immediately; collect with job_output or stop with job_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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","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","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","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"d642809d-44ee-42cb-867d-63acb3d88b7a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","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":"4fee9a25-1085-4f76-852d-7d4134da4771"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","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":"f5d9b371-8400-4993-9cac-f61fcc1aab9b"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","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":"b2d286b5-c84e-4368-91f5-0ca31a26fcc8"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"bf54676e-46ad-41b6-8938-c5b5cf9429ea"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","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":"0f63c607-e2c1-4004-af07-05621b5cbffa"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","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":"1ea99fa1-54bd-45cd-8935-041298013a91"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","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":"41fcd151-f242-4946-b94f-ff1b2a813f24"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"cb394133-a39a-4c7c-bba0-f51a06a1ddd9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","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":"0b3f3d05-bec4-4c05-9d10-def19db565ca"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","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":"116cdcf0-42dc-4220-8f01-38ed1153f903"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","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":"21e52dde-c420-408d-996b-d695d8cbcb5e"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"b7216958-bc3a-4631-bf30-31db4f96b5f4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","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":"4591bab3-687f-4bcf-b236-105f6650f603"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","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":"d950a25e-05ca-4eec-aaf8-9937b8fe4f48"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","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":"641383e7-6af3-4067-84eb-a3f9e17488cb"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,9 +53,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"6dae8392-a011-47e5-84d8-d9e4c3c19d0f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","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":"36cf385b-b4e9-4e99-831d-cd8def844f75"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","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":"fb3d7081-eba7-4169-a2fe-e05ecae357ac"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","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":"14f60758-1455-4ecb-a001-6775d61c6886"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -63,9 +63,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"b4305101-fbda-4a57-ac80-1c6cdae233f9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","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":"d00ae522-a67d-4c01-b83c-c31093efb7ba"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","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":"0097f12a-a04e-4042-b47a-4a1535708049"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","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":"efbe4f79-cb4b-43e0-979b-b9310b5f2691"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -73,6 +73,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b75bf882-4d9d-4f92-9125-0cdc18f76f25"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fdeff700-5f7b-4264-9ada-47b846a74354"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts index d92d7251e2..3e3e1b5943 100644 --- a/packages/workflow/tool-ralph/src/index.ts +++ b/packages/workflow/tool-ralph/src/index.ts @@ -406,7 +406,7 @@ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:ralph', - order: 117, + order: 116, text: '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.', }) ctx.tools.register(defineTool({ diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 899c6f8ab7..7128fe4a95 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -211,7 +211,7 @@ export function apply(ctx: Context, config: Config): void { // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ name: `tool:${toolName}`, - order: 116, + order: 115.5, text: `Use the ${toolName} 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.`, }) ctx.tools.register(defineTool({ From cd6941d5d74405f518b62afac2c05c5e7c890e9d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 24 Aug 2026 14:39:56 +0800 Subject: [PATCH 036/138] Revert "perf(ci): shorten native Windows coverage critical path" --- ...-31-coverage-exempt-heavy-suites.i18n.yaml | 4 +- ...2026-07-31-coverage-exempt-heavy-suites.md | 14 +-- ...6-07-31-coverage-exempt-heavy-suites.zh.md | 14 +-- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 14 +-- ...08-08-native-windows-pull-request-ci.zh.md | 14 +-- ...8-18-in-job-partitioned-coverage.i18n.yaml | 4 +- .../2026-08-18-in-job-partitioned-coverage.md | 14 +-- ...26-08-18-in-job-partitioned-coverage.zh.md | 14 +-- ...ws-blocks-pull-request-aggregate.i18n.yaml | 6 -- ...e-windows-blocks-pull-request-aggregate.md | 31 ------ ...indows-blocks-pull-request-aggregate.zh.md | 31 ------ .github/workflows/ci.yml | 46 ++++----- .../tests/compile/transform-corpus.spec.ts | 96 ++----------------- scripts/ci-workflow.spec.ts | 11 +-- scripts/coverage-exempt.ts | 6 -- scripts/oxlint-contract.spec.ts | 41 ++------ scripts/run-gates.spec.ts | 7 +- scripts/run-gates.ts | 9 +- 19 files changed, 80 insertions(+), 300 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md delete mode 100644 .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml index dc243ba95c..78ff70c1c1 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.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-31-coverage-exempt-heavy-suites.md -2026-07-31-coverage-exempt-heavy-suites.md: 35642c41c0140b5a39be7da4668b33b70f858a85 -2026-07-31-coverage-exempt-heavy-suites.zh.md: cefade080581e4c20a71269bef638e12559153ae +2026-07-31-coverage-exempt-heavy-suites.md: 1f468a69321b451593a9279cfebc1b457fb08a47 +2026-07-31-coverage-exempt-heavy-suites.zh.md: 7e519f44c8321b6b99c04c6af56c4cfa5b641663 diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md index 35642c41c0..1f468a6932 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md @@ -10,8 +10,6 @@ The CI coverage lane (`check:ci:coverage`) had its wall clock pinned by a handfu The decisive waste: the instrumentation tax these suites paid contributed **nothing** to the per-file 100% thresholds — the measured code they execute in-process is either outside the threshold scope already or independently fully covered by other suites. Running them instrumented traded lane time for zero information. -The Web Worker transform corpus exposed the same waste on native Windows: `transform-corpus.spec.ts` spent 279 seconds inside one 442-second coverage partition while the other seven partitions settled in 110–161 seconds. Its real checker runs package source only in a spawned Node process, outside the parent Vitest worker's v8 coverage session, so the slow partition produced no threshold data from that work. - ## Decision The `ci-coverage` aggregate splits into two parallel gates; every test still runs, and only the heavy suites stop paying the instrumentation tax: @@ -19,12 +17,10 @@ The `ci-coverage` aggregate splits into two parallel gates; every test still run - **Instrumented gate** (`test:coverage`): sets `DSH_COVERAGE_EXEMPT_HEAVY=1`, which makes `vitest.config.ts` drop the exempt suites from both projects' excludes; every remaining file runs instrumented and carries the entire threshold proof. The variable is injected through the gate's own env (the existing `Gate.env` mechanism), not the workflow-global environment, so the uninstrumented gate beside it and any local `vitest run` never see it and behave unchanged. - **Uninstrumented gate** (`test:coverage-exempt-heavy`): runs exactly the exempt suites through paired positional filters, keeping the correctness signal whole. -Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. Linux overlaps four partition children, two exempt workers, and up to eight corpus children, so this combined fan-out is the first check if that lane regresses. Native Windows runs the exempt gate after the instrumented merge, while the lightweight observational inventory overlaps the exempt work, so the full-corpus child does not compete with sixteen coverage processes. The Oxlint contract suite atomically publishes scanner-valid temporary package probes and hides its script-only probes from glob discovery. +Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. `scripts/coverage-exempt.ts` is the single roster point, holding the membership contract and the filter/exclude pairs so the two sides cannot drift. -`transform-corpus.spec.ts` discovers the complete built-bundle set once, assigns every path to exactly one of up to eight non-empty Node-loader children, and asserts the shard union before launch. `client-runtime` follows `acp-snapshot` for its pinned Vitest-state exemption, while `win32-process` follows `sandbox-windows-acl` for its pinned Koffi exemption. - ### The roster, reconciled entry by entry A suite contributes to coverage exactly when it executes measured files in-process (`coverage.include` spans the package src trees). The current roster, audited: @@ -34,7 +30,6 @@ A suite contributes to coverage exactly when it executes measured files in-proce | All 6 typert generator specs | The generator's own src | Generator src is threshold-excluded as a package (`vitest.config.ts`) — outside the threshold scope to begin with | | tools-catalog.spec additionally imports | `typert-registry` and `tool-cordis` src | Each package's own tests cover them fully (verified with focused coverage runs, zero threshold errors) | | `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts`, `scripts/translation-pairing-merge.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry | -| `packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts` | None — its package-source imports and the complete bundle sweep run in a spawned Node process | The Web Worker runtime's in-process unit suites carry its source coverage | ### Membership contract @@ -54,22 +49,15 @@ Coverage-result invariance therefore does not rest on humans maintaining the ros - **CLI `--exclude` to drop the exempt suites from the instrumented gate.** Proven ineffective: vitest 4's `cliExclude` does not participate in per-project include resolution, so under a multi-project config the exempt suites stayed selected; the env + config route replaced it. - **Lowering worker counts or raising gate concurrency.** Measured ineffective during the incident: the lane's wall clock was pinned by the longest tail files (aggregate/wall ≈ 4× effective parallelism), and the concurrency knobs moved nothing in either direction. - **Cross-runner sharding (`--shard` + blob merge).** Rejected because a matrix, artifact pipeline, and merge job would add a second workflow topology. The selected [in-job partitioning](2026-08-18-in-job-partitioned-coverage.md) uses Vitest shards only as local single-worker processes inside the existing job. -- **Keep the transform corpus in one Node process.** Rejected because its serial loader becomes the Windows heavy gate's longest tail under host contention. Eight local children retain the same file set, per-file oracle, loader-sensitive affinities, and one blocking Vitest verdict. - **Deleting or skipping the heavy suites.** Rejected: they are the sole correctness evidence for the typert generator and the scripts tooling; running them uninstrumented in parallel preserves the full signal. ## Verification Measured on CI (16-core runner): the gate segment went from 424 seconds to the two gates in parallel — `test:coverage` 95.9 s + `test:coverage-exempt-heavy` 71.1 s — with the lane converging on the slower at about 96 seconds; the instrumented gate reported zero threshold errors both before and after the split. `vitest list` verifies the env toggle adds and removes exactly the exempt set; `run-gates.spec.ts` covers the aggregate graph construction. -The Web Worker corpus entry is pinned by a partitioned aggregate that runs all 15,250 tests and reports 100% for 45,959 statements, 28,116 branches, 9,781 functions, and 40,550 lines. A focused instrumented corpus run records no package source from its child process; the paired list check proves the spec is absent from the instrumented inventory and present in the uninstrumented inventory. - -The eight-child corpus run checks the same 239 native Windows bundles with 234 exact export matches, four pinned loader exemptions, one sentinel refusal, and no drift. The ARM64 VM measures 25.44 seconds for the sharded Vitest path versus 29.59 seconds for the unsharded checker; the complete x64 job remains the contended-host timing proof. - ## Consequences - The exempt suites execute without adding instrumentation cost to the thresholded gate; partitioned wall-clock measurements belong to the [in-job partitioning decision](2026-08-18-in-job-partitioned-coverage.md). -- Native Windows schedules the exempt suites after instrumented coverage and overlaps them with observational checks; Linux retains the parallel coverage split. -- The corpus suite uses up to eight non-empty child Node loaders but emits one blocking test result; its affinity roster is part of the exemption oracle and must move with affected bundles. - `DSH_GATE_CONCURRENCY` has two schedulable gates in this lane again, so the aggregate scheduler is no longer a pass-through. - Adding a heavy suite to the roster requires the membership audit above; a wrong entry fails the instrumented gate loudly rather than eroding coverage silently. - The exempt suites no longer appear in the coverage report's file list of contributors; their correctness signal lives solely in the uninstrumented gate's pass/fail. diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md index cefade0805..7e519f44c8 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md @@ -10,8 +10,6 @@ CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试 关键的浪费在于:这些套件缴纳的插桩税对 per-file 100% 阈值**没有任何贡献**——它们进程内执行的被度量代码,要么本来就不在阈值口径内,要么已由其他套件独立满覆盖。继续在插桩下运行它们,纯粹是用 lane 时长换零信息。 -Web Worker 转换语料库在原生 Windows 上暴露了同一类浪费:`transform-corpus.spec.ts` 在一个 442 秒的覆盖率分区中占用 279 秒,而其余七个分区在 110–161 秒内完成。它的真实检查器只在 spawn 的 Node 子进程中运行包源码,处于父 Vitest worker 的 v8 覆盖率会话之外,因此这个慢分区没有从该工作中产生任何阈值数据。 - ## Decision `ci-coverage` 聚合拆成两个并行 gate,全部测试仍然执行,只有重型套件不再交插桩税: @@ -19,12 +17,10 @@ Web Worker 转换语料库在原生 Windows 上暴露了同一类浪费:`trans - **插桩 gate**(`test:coverage`):设 `DSH_COVERAGE_EXEMPT_HEAVY=1`,`vitest.config.ts` 据此从两个 project 的 exclude 中剔除豁免套件,其余全部文件照旧插桩并承担全部阈值证明。经 gate 自带 env 注入(既有 `Gate.env` 机制),不进 workflow 全局环境,因此并排的无插桩 gate 和本地直跑 `vitest run` 都看不到该变量、行为不变。 - **无插桩 gate**(`test:coverage-exempt-heavy`):用配对的 positional filter 恰好运行豁免套件,保证正确性信号不缩水。 -Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。Linux 会让 4 个分区子进程、2 个豁免 worker 与最多 8 个语料库子进程重叠,因此该通道变慢时应先检查这组并发。原生 Windows 在插桩报告合并后运行豁免门禁,同时让轻量观测性清单与豁免工作重叠,因此完整语料库子进程不会与 16 个覆盖率进程争用资源。Oxlint 约定套件会原子发布满足源码扫描要求的包内临时探针,并把只属于脚本的探针对 glob 发现隐藏。 +Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。 `scripts/coverage-exempt.ts` 是唯一名单点,集中持有成员资格约定与 filter/exclude 配对,防止两侧漂移。 -`transform-corpus.spec.ts` 只发现一次完整的已构建 bundle 集合,把每条路径恰好分配给最多 8 个非空 Node loader 子进程之一,并在启动前断言分片并集。`client-runtime` 会为固定的 Vitest 状态豁免跟在 `acp-snapshot` 之后,`win32-process` 则会为固定的 Koffi 豁免跟在 `sandbox-windows-acl` 之后。 - ### 豁免名单与逐项对账 一个套件对覆盖率有贡献,当且仅当它在进程内执行了被度量的文件(`coverage.include` = 包 src 树)。现行名单逐项核对: @@ -34,7 +30,6 @@ Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分 | typert generator 全部 6 个 spec | generator 自身 src | generator src 已整包 threshold-excluded(`vitest.config.ts`),本不在阈值口径内 | | 其中 tools-catalog.spec 额外 import | `typert-registry`、`tool-cordis` 的 src | 两包各自的测试独立满覆盖(focused coverage 实测无阈值错误) | | `scripts/install-lefthook.spec.ts`、`scripts/oxlint-contract.spec.ts`、`scripts/change-scope.spec.ts`、`scripts/translation-pairing-merge.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 | -| `packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts` | 无——包源码 import 与完整 bundle 扫描都在 spawn 的 Node 子进程中运行 | Web Worker runtime 的进程内单元套件承担其源码覆盖率 | ### 成员资格约定 @@ -54,22 +49,15 @@ per-file 100% 阈值本身就是豁免名单的守卫,名单错误无法静默 - **CLI `--exclude` 从插桩 gate 剔除豁免套件。** 实证无效:vitest 4 的 `cliExclude` 不参与 per-project include 解析,多 project 配置下豁免套件仍被选中,故改走 env + config。 - **降低 worker 数或提高 gate 并发。** 事故期间实测无效:lane 墙钟被尾部最长文件钉死(聚合/墙钟 ≈ 4× 有效并行),并发旋钮两个方向都动不了尾巴。 - **跨 runner 分片(`--shard` + blob 合并)。** 不予采用,因为 matrix、产物流水线和合并 job 会引入第二套工作流拓扑。所选的 [job 内分区](2026-08-18-in-job-partitioned-coverage.zh.md)只把 Vitest shard 用作既有 job 内的本地单 worker 进程。 -- **让转换语料库保留在一个 Node 进程中。** 不予采用,因为串行 loader 在宿主争用下成为 Windows 重型门禁的最长尾部。八个本地子进程保留相同文件集、逐文件判定器、对 loader 敏感的亲和顺序,以及一个阻断性 Vitest 判定。 - **直接删除或跳过重型套件。** 拒绝:它们是 typert generator 与 scripts 工具的唯一正确性证据,无插桩并排执行保住全部信号。 ## Verification CI 实测(16 核 runner):拆分前 gate 段 424 秒,拆分后两 gate 并行 `test:coverage` 95.9 秒 + `test:coverage-exempt-heavy` 71.1 秒,lane 收敛于较慢者约 96 秒;拆分前后插桩 gate 阈值错误均为零。`vitest list` 验证 env 开关两态恰好增删豁免集;`run-gates.spec.ts` 覆盖聚合图构造。 -Web Worker 语料库条目由分区聚合固定:它执行全部 15,250 个测试,并对 45,959 条语句、28,116 个分支、9,781 个函数和 40,550 行报告 100%。聚焦的插桩语料库运行不会记录其子进程中的包源码;配对名单检查证明该 spec 不在插桩清单中,但存在于无插桩清单中。 - -八子进程语料库运行检查相同的 239 个原生 Windows bundle,得到 234 个精确 export 匹配、四个固定 loader 豁免、一次 sentinel 拒绝和零漂移。ARM64 虚拟机上,分片 Vitest 路径耗时 25.44 秒,未分片检查器耗时 29.59 秒;完整 x64 job 仍负责证明宿主争用下的耗时。 - ## Consequences - 豁免套件在执行时不会向阈值门禁叠加插桩开销;分区墙钟数据由 [job 内分区决策](2026-08-18-in-job-partitioned-coverage.zh.md)负责记录。 -- 原生 Windows 在插桩覆盖率后调度豁免套件,并让它们与观测性检查重叠;Linux 保留并行覆盖率拆分。 -- 语料库套件使用最多 8 个非空 Node loader 子进程,但只产生一个阻断性测试结果;其亲和名单属于豁免判定器,受影响 bundle 移动时必须同步更新。 - `DSH_GATE_CONCURRENCY` 在本 lane 重新拥有两个可调度对象,聚合调度器不再是直通。 - 向名单新增重型套件必须完成上述成员资格对账;错误条目会让插桩 gate 大声失败,而不是静默侵蚀覆盖率。 - 豁免套件不再出现在覆盖率报告的贡献文件列表中;其正确性信号完全由无插桩 gate 的红绿承载。 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 index ea0f2ca087..78f432ef3b 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: d3b37bdcc19b7b06aee2aa4a067cfd317617ab67 -2026-08-08-native-windows-pull-request-ci.zh.md: 01edfece893d1d6f4cbb14a7d061e372313327f3 +2026-08-08-native-windows-pull-request-ci.md: 1f8bf7c9e5249ce218fd0d169ed82008c2dbbd36 +2026-08-08-native-windows-pull-request-ci.zh.md: efe044e601aebc92f5d9446a1a683c935dcd783b diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index d3b37bdcc1..1f8bf7c9e5 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) ## Problem -The pull-request Windows verdict needs both a fast win32 toolchain signal and a real Windows-kernel result. Wine provides the fast signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. +The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. Wine provides that critical-path signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. A coverage audit found that stale branch state had restored temporary exclusions for supported LSP sources. Native Windows therefore needed to execute the complete supported source inventory at the same 100%-per-file threshold instead of relying on a smaller platform-specific denominator. @@ -14,13 +14,13 @@ A coverage audit found that stale branch state had restored temporary exclusions The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts a separate `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. +Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup` standalone mode, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. -The native job retains its own unmasked result. [The aggregate-dependency decision](2026-08-22-native-windows-blocks-pull-request-aggregate.md) makes that result a dependency of `all checks passed`; this note owns the job's execution topology and complete inventory. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. +The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. -The 16-core lane admits eight concurrent outer gates. Workspace build, production-site validation, and sixteen-process instrumented coverage start immediately. Exempt-heavy coverage needs the build and waits for the merged coverage verdict, so its four Vitest workers and up to eight corpus children do not compete with the partition phase. The lightweight observational inventory also waits for coverage, then overlaps the exempt work; temporary package probes are atomically published with scanner-valid contents, while script-only probes use hidden filenames. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane admits four concurrent outer gates. Workspace build, production-site validation, and instrumented coverage start immediately. Exempt-heavy coverage waits for the build to pass, so its temporary Oxlint contract probes cannot race source compilation. Every observational gate waits for both coverage gates to settle, regardless of outcome, before entering an available slot; its own `needs` edges still require their predecessors to pass. This also keeps later static gates that create temporary contract files from racing either coverage scan. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`. The initial phase therefore has about ten active execution units; after build, starting exempt-heavy while build leaves keeps the peak near eleven when site and instrumented coverage are still running. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. -The 16-core allocation is the measured capacity point for this inventory. Exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits, while separate single-worker child processes retain process isolation. Sixteen-shard samples and the final hosted run complete instrumented coverage in 112.66–131.33 seconds; the job gives that phase the host before starting exempt work. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. +The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds, but used the whole host before the exempt, build, and site work was counted; eight shards deliberately trade some latency for headroom. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. @@ -36,6 +36,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Alternatives considered +**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path. + **Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. **Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism. @@ -48,7 +50,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Consequences -Wine preserves a fast early signal and its stable job identity. [The aggregate-dependency decision](2026-08-22-native-windows-blocks-pull-request-aggregate.md) makes `all checks passed` wait for both Wine and native Windows, so branch protection consumes their combined verdict through one stable required check. +Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result. Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. diff --git a/.agents/notes/implemented/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 index 01edfece89..efe044e601 100644 --- a/.agents/notes/implemented/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 @@ -6,7 +6,7 @@ Status: implemented ## 问题 -拉取请求的 Windows 判定同时需要快速的 win32 工具链信号与真实 Windows 内核结果。Wine 提供快速信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 +拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 覆盖率审计发现,陈旧分支状态恢复了针对受支持 LSP 源码的临时排除项。因此,原生 Windows 需要按同一逐文件 100% 阈值执行完整的受支持源码清单,而不能依赖缩小后的平台专用分母。 @@ -14,13 +14,13 @@ Status: implemented [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个单独的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 的 standalone 模式提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 -原生作业保留自身未被掩盖的结果。[聚合依赖决策](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md)让该结果成为 `all checks passed` 的依赖项;本文负责该作业的执行拓扑与完整清单。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 +原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 -16 核通道最多同时运行 8 道外层门禁。工作区构建、生产网站验证与 16 进程插桩覆盖率会立即启动。豁免重型覆盖率依赖构建并等待覆盖率报告合并,因此其 4 个 Vitest worker 与最多 8 个语料库子进程不会和分区阶段争用资源。轻量观测性清单同样等待覆盖率,随后与豁免工作重叠;包内临时探针会以满足源码扫描要求的完整内容原子发布,只属于脚本的探针则使用隐藏文件名。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证与插桩覆盖率会立即启动。豁免重型覆盖率等待构建通过后再启动,使其临时 Oxlint 约定探针不会与源码编译竞态。每道观测性门禁只等待两道覆盖率门禁以任意结果结算后再进入可用槽位;各门禁自身的 `needs` 边仍要求前置门禁通过。这也使随后创建临时约定文件的静态门禁不会与任一覆盖率扫描竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker。因此初始阶段约有 10 个活动执行单元;构建结束并启动豁免重型门禁后,如果网站与插桩覆盖率仍在运行,峰值约为 11 个。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 -16 核配置是这项清单经实测选定的容量规格。在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出,而相互独立的单 worker 子进程保留进程隔离。16 分片样本与最终托管运行会在 112.66–131.33 秒内完成插桩覆盖率;作业会先把宿主资源交给该阶段,再启动豁免工作。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒,但还未计入豁免、构建与网站工作就已经占满整台宿主;8 个分片刻意用部分延迟换取余量。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 @@ -36,6 +36,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 曾考虑的替代方案 +**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。 + **只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 **将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。 @@ -48,7 +50,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 后果 -Wine 保留快速的早期信号与稳定作业身份。[聚合依赖决策](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md)让 `all checks passed` 同时等待 Wine 与原生 Windows,因此分支保护通过一个稳定的必需检查采用二者的合并判定。 +Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。 尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建,在标准镜像上明显更慢;但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index d8ee642db9..b48f880c61 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: 8d8684b6299f4c2dc359ced09048d0b6acb9ed42 -2026-08-18-in-job-partitioned-coverage.zh.md: 4781b5e0abb8dc4dde8004b08f5a5f105ef50b80 +2026-08-18-in-job-partitioned-coverage.md: 532c145f5b66bd6574f9ee167c12c739fd4d7fa9 +2026-08-18-in-job-partitioned-coverage.zh.md: dc8a089a7c089338b775e49fdfe67f134f077704 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index 8d8684b629..532c145f5b 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -12,13 +12,13 @@ The optimization must retain every test and the merged per-file 100% thresholds. ## Decision -The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 16; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate. +The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 8; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate beside the instrumented work. When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker and one `--shard=/` option. Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process. The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Build, production-site validation, and instrumented coverage start immediately on native Windows. The exempt gate needs the build and waits for instrumented coverage to settle, so its full-corpus children and temporary Oxlint probes do not compete with the sixteen partitions; it then receives four workers from the budget of 12. The observational inventory also waits for instrumented coverage, then overlaps the exempt gate within an eight-worker outer budget. Ordering uses `after`, so both groups still run after an instrumented failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. Build, production-site validation, and instrumented coverage start immediately; exempt-heavy coverage starts only after build passes, preventing its temporary Oxlint probes from racing source compilation. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics @@ -30,9 +30,7 @@ A normal failed test still emits a blob through `--coverage.reportOnFailure`, al `scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, the complete Windows inventory with its blocking split, and unbuffered streamed output. React fake-timer cases that can move between partitions advance timers inside `act()`; geometry-dependent portal tests stub their element rectangles so a different shard schedule cannot turn deferred updates or jsdom coordinates into coverage-only failures. -Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds. Sixteen is the fixed Windows count. The exempt gate waits for their merged verdict, so the partition phase overlaps only build and production-site validation: at most eighteen active execution units on a 16-core runner, rather than adding exempt workers to that peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. - -The native ARM64 VM runs the full transform corpus in 29.59 seconds without coverage partitions and in 25.44 seconds through the eight-child Vitest path. A concurrent self-hosted x64 job stretched the former serial test to 279.13 seconds while one instrumented partition reached 442.45 seconds. The Windows graph separates the partition and exempt phases before applying its fixed sixteen-way coverage fan-out. +Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds, but the sixteen-way schedule could put more than twenty active execution units beside build and exempt coverage on a 16-core runner. Eight partitions keep separate-process isolation while accepting a longer feedback path for a materially lower peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. ## Alternatives considered @@ -40,18 +38,14 @@ The native ARM64 VM runs the full transform corpus in 29.59 seconds without cove **Raise the Vitest worker count inside one instrumented process.** Rejected because completed Windows trials at higher fan-out exposed worker exits, fixture instability, and Node 24 CJS lexer failures. Separate single-worker processes preserve isolation while still executing the selected partitions concurrently. -**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's sixteen-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. +**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's eight-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. **Apply thresholds independently in each partition.** Rejected because every partition intentionally sees only part of the suite and would report false uncovered files. Threshold ownership belongs to the merged report. -**Overlap the Windows exempt gate with instrumented partitions.** Rejected because the full-corpus checker is fast in isolation but multiplies under partition contention. The post-coverage phase uses available workers for the exempt and observational checks without changing either verdict. - ## Consequences Coverage pays one Vitest startup/configuration cost per partition and one report-merge cost, but it avoids another workflow topology and keeps one final threshold verdict. Partition output may interleave, while the partition start labels and Vitest file identities retain attribution. Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one. -Windows uses two resource phases inside the same job: sixteen isolated coverage processes through the merged threshold verdict, then the four-worker exempt gate beside lightweight observational checks. - Future tuning starts from completed runs at one fixed configuration. Slow progress alone never raises partition count or outer concurrency, because repeated restarts would erase the only evidence needed to choose a stable setting. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index 4781b5e0ab..dc8a089a7c 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 16;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁。 +普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 8;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁与插桩工作并排运行。 启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker,并各自接收一个 `--shard=/` 选项。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 上的构建、生产网站验证与插桩覆盖率会立即启动。豁免门禁要求构建通过,并等待插桩覆盖率结算,因此其完整语料库子进程和临时 Oxlint 探针不会与十六个分区争用资源;随后它从 12 的预算中获得 4 个 worker。观测性清单也等待插桩覆盖率,然后在八 worker 的外层预算内与豁免门禁重叠。该顺序使用 `after`,因此插桩失败后两组检查仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。构建、生产网站验证与插桩覆盖率会立即启动;豁免重型覆盖率只在构建通过后启动,避免其临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 @@ -30,9 +30,7 @@ Status: implemented `scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器;依赖几何位置的 portal 测试会固定元素矩形,使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。 -已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒。Windows 固定使用 16 个分区。豁免门禁等待其合并判定,因此分区阶段只与构建和生产网站验证重叠:16 核运行器上最多有 18 个活动执行单元,不会再把豁免 worker 加入该峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 - -原生 ARM64 虚拟机在没有覆盖率分区时用 29.59 秒运行完整转换语料库,通过八子进程 Vitest 路径时用 25.44 秒。一个并发运行的自托管 x64 job 把此前的串行测试拉长到 279.13 秒,同时一个插桩分区达到 442.45 秒。Windows 门禁图先分离分区阶段与豁免阶段,再应用固定的 16 路覆盖率扇出。 +已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒,但 16 路调度与构建、豁免覆盖率并行时,会在 16 核运行器上形成超过 20 个活动执行单元。8 个分区继续保留独立进程隔离,同时接受更长的反馈路径,以显著降低峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 ## 曾考虑的替代方案 @@ -40,18 +38,14 @@ Status: implemented **提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。 -**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 16 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 +**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 **在每个分区内独立应用阈值。** 不予采用,因为每个分区有意只看到套件的一部分,会误报未覆盖文件。阈值归合并报告所有。 -**让 Windows 豁免门禁与插桩分区重叠。** 不予采用,因为完整语料库检查器在独立运行时很快,却会在分区争用下成倍变慢。覆盖率后的阶段把可用 worker 用于豁免检查与观测性检查,不改变任何一项判定。 - ## 后果 每个分区都要支付 1 次 Vitest 启动与配置开销,最后还要执行 1 次报告合并,但它不引入另一套工作流拓扑,并保留唯一的最终阈值判定。分区输出可能交错,但分区启动标签和 Vitest 文件标识仍可用于归因。 Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。 -Windows 在同一个 job 内使用两个资源阶段:十六个隔离的覆盖率进程先产出合并阈值判定,随后四 worker 的豁免门禁与轻量观测性检查并排运行。 - 未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发,因为反复重启会抹掉选择稳定设置所需的唯一证据。 diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml deleted file mode 100644 index 8bf3d97c55..0000000000 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.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-22-native-windows-blocks-pull-request-aggregate.md -2026-08-22-native-windows-blocks-pull-request-aggregate.md: ddec9536cbb350d3792ae547150b175ef21f1b9e -2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md: 94fa8b3836c15b9c977c39c7539cbb1be5fc882b diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md deleted file mode 100644 index ddec9536cb..0000000000 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Native Windows blocks the pull-request aggregate - -Status: implemented - -English | [中文](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md) - -## Problem - -Wine reaches blocking win32 toolchain paths quickly, but it cannot prove behavior that depends on the NT kernel, NTFS, PowerShell, Windows process control, or native addons. An `all checks passed` result that can succeed while the complete native job is pending or failed does not enforce the repository's supported Windows behavior. - -The native job runs the complete supported-source coverage denominator and its owning Windows acceptance inventory. Its optimized 16-core hosted run completes within the five-minute target, making that higher-fidelity result short enough for the required pull-request path. - -## Decision - -The `all-checks-passed` job in [ci.yml](../../../../.github/workflows/ci.yml) lists both `windows` and `windows-native` in `needs`. Its existing `if: always()` verdict treats a failed, cancelled, or skipped native job like any other unsuccessful dependency, so `all checks passed` cannot succeed until the real-Windows job succeeds. - -Branch protection continues to require the single stable `all checks passed` context rather than adding the native job name as another protected context. The [dual Windows topology](2026-08-08-native-windows-pull-request-ci.md) owns each job's host, failover selector, and inventory; this note owns their blocking relationship. The aggregate bookkeeping job follows the Linux failover selector for its own runner while `needs` independently waits for the pool selected by `DSH_CI_FAILOVER_WINDOWS`. - -## Alternatives considered - -**Keep native Windows informational.** This preserves the shortest aggregate path, but permits a merge while the highest-fidelity supported Windows verdict is pending or red. - -**Require `windows node 24 / native complete` directly in branch protection.** This duplicates workflow topology in repository settings and makes a job-name change a control-plane migration. The aggregate already provides one stable required context and fails closed over unsuccessful dependencies. - -**Remove Wine from the aggregate.** Native Windows provides higher fidelity, but Wine still returns a faster win32 build and production-site signal, preserves the compatibility topology, and gives maintainers earlier failure evidence while the native inventory runs. - -## Consequences - -Every merge waits for native Windows runner capacity and for the complete native job to finish. A failure, cancellation, or skip in that job makes `all checks passed` fail; a passing Wine job alone is insufficient. - -The workflow remains one pull-request Action with one native Windows job, unchanged test coverage, and unchanged gate semantics inside that job. The required aggregate gains the native job's measured duration without adding a separately managed branch-protection context. diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md deleted file mode 100644 index 94fa8b3836..0000000000 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 原生 Windows 阻断拉取请求聚合流程 - -Status: implemented - -[English](2026-08-22-native-windows-blocks-pull-request-aggregate.md) | 中文 - -## 问题 - -Wine 能快速触达阻断性 win32 工具链路径,但无法证明依赖 NT 内核、NTFS、PowerShell、Windows 进程控制或原生插件的行为。如果 `all checks passed` 能在完整原生作业仍处于待处理或失败状态时成功,它就没有强制验证仓库所支持的 Windows 行为。 - -原生作业会运行完整的受支持源码覆盖率分母及其所属 Windows 验收清单。优化后的 16 核托管运行能在五分钟目标内完成,因此这项保真度更高的结果足够短,可以进入必需的拉取请求路径。 - -## 决策 - -[ci.yml](../../../../.github/workflows/ci.yml) 中的 `all-checks-passed` 作业会在 `needs` 中同时列出 `windows` 与 `windows-native`。其现有的 `if: always()` 判定会像处理其他未成功依赖项一样处理失败、取消或跳过的原生作业,因此真实 Windows 作业成功前,`all checks passed` 无法成功。 - -分支保护继续要求单一且稳定的 `all checks passed` 检查,而不把原生作业名称添加为另一个受保护检查。[Windows 双通道拓扑](2026-08-08-native-windows-pull-request-ci.zh.md)负责每个作业的宿主、故障转移选择器与清单;本文负责二者的阻断关系。聚合记账作业为自身运行器采用 Linux 故障转移选择器,而 `needs` 会独立等待 `DSH_CI_FAILOVER_WINDOWS` 所选池中的作业。 - -## 曾考虑的替代方案 - -**让原生 Windows 只提供信息。** 这会保留最短的聚合路径,但也允许在保真度最高的受支持 Windows 判定仍处于待处理或红灯状态时合并。 - -**在分支保护中直接要求 `windows node 24 / native complete`。** 这会在仓库设置中复制工作流拓扑,并使作业名称变更成为控制面迁移。现有聚合流程已经提供一个稳定的必需检查,并会对未成功的依赖项快速失败。 - -**从聚合流程移除 Wine。** 原生 Windows 的保真度更高,但 Wine 仍能更快返回 win32 构建与生产网站信号、保留兼容性拓扑,并在原生清单运行期间更早地为维护者提供失败证据。 - -## 后果 - -每次合并都会等待原生 Windows 运行器容量与完整原生作业结束。该作业失败、取消或跳过都会使 `all checks passed` 失败;仅 Wine 作业通过并不足够。 - -工作流仍然是单个拉取请求 Action,并保留一个原生 Windows 作业、不变的测试覆盖率以及该作业内不变的门禁语义。必需聚合流程会增加原生作业的实测时长,但无需新增单独管理的分支保护检查。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f02c2919f..f9d4cac33a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,10 +307,10 @@ jobs: secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - # The pull-request Windows signals cover complementary hosts. The two fast - # win32 toolchain surfaces (workspace build, production site) execute with - # real, checksum-verified Windows Node under Wine on standard hosted Linux. - # The windows-native job below keeps the complete native-kernel inventory — + # 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 @@ -397,12 +397,13 @@ jobs: if: always() run: wineserver -k 2>/dev/null || true - # Every pull request also gets a real Windows-kernel signal. Its unmasked - # conclusion is a dependency of all-checks-passed, so failure, cancellation, - # or omission blocks the required verdict. Under normal operation it runs on - # the hosted larger runner. DSH_CI_FAILOVER_WINDOWS=selfhosted retargets it - # 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 + # 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 + # Windows failover (DSH_CI_FAILOVER_WINDOWS=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. This Windows switch is independent of the Linux # DSH_CI_FAILOVER_LINUX variable that retargets the three required Linux jobs # and the all-checks-passed verdict above. @@ -416,16 +417,12 @@ jobs: name: windows node 24 / native complete timeout-minutes: 120 env: - # Partitioned coverage finishes before the heavy uninstrumented gate; - # the latter can use four workers without competing with sixteen shards. - DSH_COVERAGE_MAX_WORKERS: '12' - DSH_COVERAGE_PARTITIONS: '16' + DSH_COVERAGE_MAX_WORKERS: '6' + DSH_COVERAGE_PARTITIONS: '8' # Instrumented process and polling fixtures can exceed Vitest's defaults # under the complete lane's concurrent gate load. DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' - # After the threshold merge, the heavy gate overlaps lightweight - # observational checks within this post-coverage worker budget. - DSH_GATE_CONCURRENCY: '8' + DSH_GATE_CONCURRENCY: '4' DSH_PUBLINT_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 @@ -461,10 +458,10 @@ 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`, including both the Wine `windows` job and the real-kernel - # `windows-native` job. (`needs` cannot reach across workflow files; the - # master-only jobs in ci-master.yml are intentionally not part of this PR - # verdict.) + # `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; the master-only jobs in + # ci-master.yml are intentionally not part of this PR verdict.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including @@ -475,15 +472,14 @@ jobs: # provisioning — and under Linux failover it follows the same selector as # the worker jobs it aggregates, so a standard-hosted outage cannot strand # the branch-protection verdict either. It retargets with the Linux switch - # (DSH_CI_FAILOVER_LINUX), not the Windows one, because this bookkeeping job - # itself runs on Linux; the native dependency resolves its Windows pool - # independently. + # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the + # required Linux workers and runs on the vm-backup pool. runs-on: >- ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && 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, python-runtime, windows, windows-native] + needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows] if: always() && github.event_name == 'pull_request' steps: - name: Fail if any needed job did not succeed diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts index 8ed031ebc3..8f1d745ea9 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts @@ -11,107 +11,23 @@ * exemptions as stale. The gate's own note applies to itself: a gate whose * verdict depends on how it was launched is not a gate. * - * Eight Node-loader processes divide the discovered files, and the union check - * proves that each bundle appears once. The two test-support bundles and the - * ACL/win32-process pair stay in one ordered shard because their pinned loader - * exemptions depend on the same preceding module state as the unsharded - * checker. - * * The corpus is the build output, so this skips on a tree that has none. */ -import { spawn } from 'node:child_process' -import { globSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { expect, test } from 'vitest' const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url)) -const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) -const corpusShards = 8 -const shardAffinity = new Set([ - // client-runtime needs acp-snapshot to establish Vitest's internal state. - 'packages/test-support/acp-snapshot/lib/index.js', - 'packages/test-support/client-runtime/lib/index.js', - // win32-process observes Koffi's duplicate type names after the ACL bundle. - 'packages/sandbox/sandbox-windows-acl/lib/index.js', - 'packages/subprocess/win32-process/lib/index.js', -]) -interface CorpusResult { - readonly output: string - readonly status: number | null - readonly error?: string -} - -/** @returns Built bundle paths in the same stable order as the checker. */ -function discoverBuiltBundles(): string[] { - return [ - ...globSync('packages/*/*/lib/index.js', { cwd: repositoryRoot }), - ...globSync('vendor/*/lib/index.js', { cwd: repositoryRoot }), - ].map(path => path.replaceAll('\\', '/')).sort() -} - -/** @returns Non-empty shards with every bundle assigned once and loader affinity preserved. */ -function partitionBundles(files: readonly string[], count: number): string[][] { - const partitions = Array.from({ length: count }, () => [] as string[]) - files.forEach((file, index) => { - const assigned = shardAffinity.has(file) ? 0 : index % count - partitions[assigned]?.push(file) - }) - return partitions.filter(partition => partition.length > 0) -} - -/** @returns One isolated Node-loader corpus shard. */ -function runCorpusShard(files: readonly string[]): Promise { - return new Promise((resolveResult) => { - let output = '' - let spawnError: string | undefined - const child = spawn(process.execPath, ['--import', 'tsx/esm', runner, ...files], { - cwd: repositoryRoot, - stdio: ['ignore', 'pipe', 'pipe'], - }) - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { output += chunk }) - child.stderr.on('data', (chunk: string) => { output += chunk }) - child.once('error', (reason) => { spawnError = reason.message }) - child.once('close', (status) => { - resolveResult({ - output, - status, - ...spawnError === undefined ? {} : { error: spawnError }, - }) - }) - }) -} - -test('partitions every bundle once while retaining loader-state affinity', () => { - const files = [ - 'packages/example/first/lib/index.js', - ...shardAffinity, - 'packages/example/last/lib/index.js', - ] - const shards = partitionBundles(files, corpusShards) - - expect(shards.every(shard => shard.length > 0)).toBe(true) - expect(shards.flat().sort()).toEqual([...files].sort()) - expect(shards[0]?.filter(file => shardAffinity.has(file))).toEqual(files.filter(file => shardAffinity.has(file))) -}) - -test('every built bundle transforms to the export shape Node loads', async (context) => { - const files = discoverBuiltBundles() - if (files.length === 0) { +test('every built bundle transforms to the export shape Node loads', (context) => { + const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', runner], { encoding: 'utf8' }) + const output = `${finished.stdout}${finished.stderr}` + if (output.includes('no built bundles found')) { context.skip('the workspace has no build output to sweep') return } - const shards = partitionBundles(files, Math.min(corpusShards, files.length)) - expect(shards.flat().sort()).toEqual(files) - const finished = await Promise.all(shards.map(runCorpusShard)) - const output = finished.map((result, index) => `shard ${String(index + 1)}/${String(shards.length)}:\n${result.output}`).join('\n') // The runner prefixes every finding with '- ', so a failure reads as the // findings themselves rather than as a diff of its whole report. expect(output.split('\n').filter(line => line.startsWith('- ')).join('\n')).toBe('') - for (const result of finished) { - expect(result.error, output).toBeUndefined() - expect(result.status, output).toBe(0) - } + expect(finished.status, output).toBe(0) }, 900_000) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 116bb84e3c..56334523aa 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -36,7 +36,7 @@ describe('CI workflow', () => { } }) - it('keeps required Wine and native Windows jobs with failover, plus 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') const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml') if (!isRecord(workflow.jobs) @@ -73,7 +73,7 @@ describe('CI workflow', () => { expect(windows.if).toBe("github.event_name == 'pull_request'") expect(commandSteps.some(step => step.run.includes('wine-windows-gates.sh'))).toBe(true) - // windows-native: blocking native job with failover, runs windows-complete. + // windows-native: non-blocking native job with failover, runs windows-complete. // Its pool is resolved by the Windows-specific switch. expect(typeof windowsNative['runs-on']).toBe('string') expect(windowsNative['runs-on']).toContain('DSH_CI_FAILOVER_WINDOWS') @@ -84,10 +84,7 @@ describe('CI workflow', () => { expect(windowsNative.name).toBe('windows node 24 / native complete') expect(windowsNative.if).toBe("github.event_name == 'pull_request'") expect(windowsNative.env).toMatchObject({ - DSH_COVERAGE_MAX_WORKERS: '12', - DSH_COVERAGE_PARTITIONS: '16', DSH_COVERAGE_TEST_TIMEOUT_MS: '30000', - DSH_GATE_CONCURRENCY: '8', }) const nativeSteps = windowsNative.steps as unknown[] const nativeCommandSteps = nativeSteps.filter((step): step is Record & { run: string } => ( @@ -104,9 +101,9 @@ describe('CI workflow', () => { expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') - // Aggregate: both complementary Windows jobs are required. + // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') - expect(aggregate.needs).toContain('windows-native') + expect(aggregate.needs).not.toContain('windows-native') expect(aggregate.needs).not.toContain('serial-windows') // Linux failover is a separate switch: the three required Linux workers diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index 2e68b53b60..eff6ca2b13 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -39,10 +39,4 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ { filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' }, { filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' }, { filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' }, - // The real corpus transform runs package src only in a spawned Node process, - // outside the parent Vitest worker's v8 coverage session. - { - filter: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - exclude: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - }, ] diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index c1f39f8cc8..def26f0a78 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -1,8 +1,8 @@ import { spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, join, relative } from 'node:path' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript' import { describe, expect, it } from 'vitest' @@ -39,26 +39,6 @@ function normalizedOutput(result: ReturnType): string { return `${result.stdout}${result.stderr}`.replaceAll('\\', '/') } -/** @returns A transient filename excluded from concurrent repository-wide glob discovery. */ -function hiddenProbeName(prefix: string, suffix: string, extension = '.ts'): string { - return `.${prefix}-${suffix}${extension}` -} - -/** - * Publish a complete probe so concurrent repository scans never read a partial write. - * @param path - Final probe path that the owning project must discover. - * @param source - Complete TypeScript source to publish. - */ -async function publishProbe(path: string, source: string): Promise { - const staging = join(dirname(path), `.${basename(path)}.staging`) - try { - await writeFile(staging, source) - await rename(staging, path) - } finally { - await rm(staging, { force: true }) - } -} - async function writeContractConfig(suffix: string): Promise { const path = join(repositoryRoot, `.oxlintrc.contract-${suffix}.json`) await writeFile(path, JSON.stringify({ extends: ['./.oxlintrc.json'], ignorePatterns: [] })) @@ -79,8 +59,7 @@ describe('Oxlint executable contract', () => { ['example', 'examples/headless-agent/tests', 'tsconfig.host.json'], ['website', 'website', 'tsconfig.host.json'], ] as const - const source = `/** Produce a settled promise for type-aware linting. */ -export function probePromise(): Promise { + const source = `export function probePromise(): Promise { return Promise.resolve() } @@ -91,7 +70,7 @@ probePromise() const paths: Array = [] for (const [label, parent, tsconfig, extension = '.ts'] of probes) { const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}${extension}`) - await publishProbe(path, source) + await writeFile(path, source) paths.push([label, relative(repositoryRoot, path), tsconfig]) } const clientScript = 'scripts/client-bundle-purity.spec.ts' @@ -109,7 +88,7 @@ probePromise() expect(result.error).toBeUndefined() expect(result.status, output).toBe(1) for (const [label, path, tsconfig] of paths) { - expect(output, label).toContain(`${path.replaceAll('\\', '/')}:6:1: Promises must be awaited`) + expect(output, label).toContain(`${path.replaceAll('\\', '/')}:5:1: Promises must be awaited`) expect(output, `${label} project`).toContain( `Got tsconfig for file ${join(repositoryRoot, path).replaceAll('\\', '/')}: ${join(repositoryRoot, tsconfig).replaceAll('\\', '/')}`, ) @@ -131,7 +110,7 @@ probePromise() it('runs JavaScript compatibility and nursery rules', async () => { const suffix = randomUUID() const configPath = await writeContractConfig(suffix) - const path = join(repositoryRoot, 'scripts', hiddenProbeName('oxlint-contract', suffix)) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) const source = `export function firstProbe(): number { const first = 1 const second = 2 @@ -251,7 +230,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('reports an unused suppression', async () => { const suffix = randomUUID() const configPath = await writeContractConfig(suffix) - const path = join(repositoryRoot, 'scripts', hiddenProbeName('oxlint-contract', suffix)) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) try { await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n') @@ -301,7 +280,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + expect(stagedConfig.ignorePatterns).not.toContain('packages/typert/generator/tests/fixtures/type-model/**') const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, 'export const value={answer:1};\n') const lint = runOxlint([ @@ -324,7 +303,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('preserves successful fix output channels', async () => { const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n') @@ -348,7 +327,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('prints only the final diagnostics when a fix retry still fails', async () => { const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, `export const longProbe = ${'1 + '.repeat(80)}1\n`) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index e7188f7709..1535b27ba3 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -169,13 +169,14 @@ describe('gate graph validation', () => { expect(byId.get('coverage')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build') - expect(byId.get('coverage-exempt-heavy')?.after).toContain('coverage') expect(observational).not.toHaveLength(0) for (const gate of observational) { const completeGate = byId.get(gate.id) expect(completeGate?.allowFailure).toBe(true) - expect(completeGate?.after).toContain('coverage') - expect(completeGate?.after).not.toContain('coverage-exempt-heavy') + expect(completeGate?.after).toEqual(expect.arrayContaining([ + 'coverage', + 'coverage-exempt-heavy', + ])) expect(completeGate?.needs).toEqual(gate.needs) } }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e24e662153..45af4dc99e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -472,12 +472,9 @@ function ciWindowsBlockingGates(): Gate[] { function ciWindowsCompleteGates(): Gate[] { const coverage = coverageGates().map(gate => gate.id === 'coverage-exempt-heavy' - ? { - ...gate, - needs: [...new Set(['build', ...(gate.needs ?? [])])], - after: [...new Set(['coverage', ...(gate.after ?? [])])], - } + ? { ...gate, needs: [...new Set(['build', ...(gate.needs ?? [])])] } : gate) + const coverageAfter = coverage.map(gate => gate.id) const observational = ciWindowsObservationalGates() // The required production site replaces the observational MPA build; both // VitePress modes write the same output directory and cannot overlap. @@ -485,7 +482,7 @@ function ciWindowsCompleteGates(): Gate[] { .map(gate => ({ ...gate, allowFailure: true, - after: [...new Set(['coverage', ...(gate.after ?? [])])], + after: [...new Set([...coverageAfter, ...(gate.after ?? [])])], })) return [ ciBuildGate(), From 558f08780cceaeea0f1e8429120a50c46a9d35d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 14:51:03 +0800 Subject: [PATCH 037/138] =?UTF-8?q?refactor(attachment):=20=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E5=AE=BF=E4=B8=BB=E4=BD=8D=E7=BD=AE=E4=B8=8E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=AE=BF=E9=97=AE=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 4 +- ...08-20-unified-image-request-pipeline.zh.md | 4 +- ...08-21-model-readable-image-paths.i18n.yaml | 4 +- .../2026-08-21-model-readable-image-paths.md | 26 +++++--- ...026-08-21-model-readable-image-paths.zh.md | 26 +++++--- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 20 ++----- docs/subsystems/attachment.zh.md | 20 ++----- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 12 ++++ docs/subsystems/llm-streaming.zh.md | 12 ++++ .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 6 +- .../attachment/attachment-local/README.zh.md | 6 +- .../attachment/attachment-local/package.json | 3 - .../attachment/attachment-local/src/index.ts | 11 +--- .../attachment-local/tests/index.spec.ts | 24 ++++---- .../attachment/attachment-local/tsconfig.json | 1 - .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 6 +- packages/attachment/attachment/README.zh.md | 6 +- packages/attachment/attachment/src/index.ts | 8 +-- packages/attachment/attachment/src/types.ts | 8 --- .../attachment/attachment/tests/index.spec.ts | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 12 ++-- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/README.zh.md | 6 +- packages/llm/llm-deepseek/package.json | 2 + packages/llm/llm-deepseek/src/adapter.ts | 11 +++- packages/llm/llm-deepseek/src/index.ts | 8 ++- packages/llm/llm-deepseek/src/serialize.ts | 18 +++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 2 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 9 +-- packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 6 +- packages/llm/llm-pi-ai/README.zh.md | 6 +- packages/llm/llm-pi-ai/package.json | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 18 ++++-- packages/llm/llm-pi-ai/src/context.ts | 60 +++++++++++-------- packages/llm/llm-pi-ai/src/index.ts | 8 ++- packages/llm/llm-pi-ai/tests/context.spec.ts | 46 +++++++++----- packages/llm/llm-pi-ai/tests/convert.spec.ts | 10 +++- packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 4 +- packages/llm/llm/README.zh.md | 4 +- packages/llm/llm/src/content.ts | 47 +++++++++++++-- packages/llm/llm/tests/content.spec.ts | 32 +++++++++- pnpm-lock.yaml | 12 ++-- scripts/gen-cordis-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 10 ++-- 57 files changed, 366 insertions(+), 236 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index cca3335220..66ba6d1139 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 7af0bea0acb117844b258a0333e97e97facf493c -2026-08-20-unified-image-request-pipeline.zh.md: ecdb71548190ba22573a5e05c3b3fde76c28d511 +2026-08-20-unified-image-request-pipeline.md: 85296f1d8bd7d6ee458f8bc4230e52f1d3128bff +2026-08-20-unified-image-request-pipeline.zh.md: bcbc0110001e9f974e57a456140e9ecdf4eb888c diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 7af0bea0ac..85296f1d8b 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -26,11 +26,11 @@ Batch admission prepares and verifies every normalized attachment once before pu The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. -Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a deterministic per-image placeholder that retains its identity and current provider access facts, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references. ### Stable handles -Every retained request image is preceded by its display name or complete attachment id, actual request dimensions, and current provider access facts. The local provider supplies the absolute read-only normalized-object path, normalized dimensions, and media type. The descriptor states that normalization or request projection may have resized or re-encoded the upload, so the model cannot infer original upload properties from either representation. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. The path is derived from the logged reference and current provider root at request time; it does not enter the durable reference or session log. +Every retained request image is preceded by its display name or complete attachment id and actual request dimensions. The attachment provider can supply its host object location; the LLM consumer combines it with the current filesystem mapping before adding an absolute read-only path, normalized dimensions, and media type. The descriptor states that normalization or request projection may have resized or re-encoded the upload, so the model cannot infer original upload properties from either representation. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. The path is resolved independently from the deterministic request version and does not enter its `variantId`, the durable reference, or the session log. ### DeepSeek Files lifecycle diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index ecdb715481..bcbc011000 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -26,11 +26,11 @@ Status: implemented `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成确定性的逐图占位文本,保留自己的身份和当前提供方访问事实,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 ### 稳定句柄 -每张保留请求图片前都有显示名称或完整附件 ID、实际请求尺寸,以及当前提供方访问事实。本地提供方会给出规范化对象的绝对只读路径、规范化尺寸和媒体类型。描述会说明规范化或请求投影可能缩小或重新编码上传图片,因此模型不能从任一版本推断上传图片原本的属性。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。路径在请求时根据已记录引用和当前提供方根目录派生,不进入持久引用或会话日志。 +每张保留请求图片前都有显示名称或完整附件 ID,以及实际请求尺寸。附件提供方可以给出宿主对象位置;LLM 消费方将其与当前文件系统映射组合后,再加入绝对只读路径、规范化尺寸和媒体类型。描述会说明规范化或请求投影可能缩小或重新编码上传图片,因此模型不能从任一版本推断上传图片原本的属性。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。路径独立于确定性请求版本解析,不进入其 `variantId`、持久引用或会话日志。 ### DeepSeek Files 生命周期 diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml index 314dabaa74..b9516c493a 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.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-21-model-readable-image-paths.md -2026-08-21-model-readable-image-paths.md: 729e6347e8623694e1cc4f4779bf71447006cb08 -2026-08-21-model-readable-image-paths.zh.md: d67817cf699afb6b094524388dd6d5a48a653e90 +2026-08-21-model-readable-image-paths.md: 728d25ee84836a07805f9d8728e47e9319e0863d +2026-08-21-model-readable-image-paths.zh.md: 2596dec5a21a8908d5875ab031bed6655b2305ca diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md index 729e6347e8..728d25ee84 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md @@ -1,4 +1,4 @@ -# Agent Note: Provider-resolved image paths in model requests +# Agent Note: Execution-world image paths in model requests Status: implemented @@ -10,21 +10,29 @@ An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable ## Decision -`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute object path from the resolved `DSH_HOME`, attachment storage version, and validated digest, then asks the mounted `ctx.fs` to map that host file into its execution world. `FileSystem.processPathFromHostPath(hostPath)` returns no mapping by default; the host-backed provider returns the absolute process path, while E2B and other remote providers return no access facts. A request version carries available facts transiently for serialization. +`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageHostPath(ref)` exposes only the attachment provider's optional host object location. The local provider derives that absolute path from the resolved `DSH_HOME`, attachment storage version, and validated digest; it does not inspect the model-tool execution world. -The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the provider-resolved normalized path when available. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. +Each LLM provider plugin supplies the bridge at request assembly. It combines the attachment store with the current `ctx.fs.processPathFromHostPath(hostPath)` mapping and passes the resulting `ImageAttachmentAccess` independently into retained-image serialization and offload placeholders. The filesystem service is authoritative for this question because the mounted filesystem and subprocess providers share one execution world. A separate subprocess-presence check would not prove path reachability. Host-backed filesystems return a process path; E2B and other remote backends without a shared mount return no mapping. -Request-size offload requires a per-image placeholder function; the previous shared placeholder constant and its byte-bound wrapper had no remaining production caller and are removed. DeepSeek and pi-ai replace each omitted occurrence with its own attachment identity and current access facts without reading or transforming the omitted object. Offload selection, byte accounting, and quantized prefix behavior remain unchanged. +`RequestImageAttachment` remains a deterministic version selected by the attachment and route policy. It contains `variantId`, encoded bytes, dimensions, and encoding metadata, but no access path. Execution-world access may change with the host or mounted providers and does not participate in `variantId`. -Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Access resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. +The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the execution-world path when the bridge resolves one. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. -Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the providers mounted for the current process. Restoring the same session with a different `DSH_HOME` produces the path valid on that host; restoring it with a remote execution world that has no shared mount produces no path. Published attachment objects use owner-read-only mode, including deduplicated objects, and model instructions require a writable copy for modifications. +Request-size offload requires a per-image placeholder function; the previous shared placeholder constant and its byte-bound wrapper had no remaining production caller and are removed. DeepSeek and pi-ai replace each omitted occurrence with its own attachment identity and access resolved for that request without reading or transforming the omitted object. Offload selection, byte accounting, and quantized prefix behavior remain unchanged. + +Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Host-location resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. + +Absolute paths stay out of session events and request-image versions. Model-visible path text is reconstructed from the logged attachment reference, the current attachment provider, and the filesystem mounted for the current execution world. Restoring the same session with a different `DSH_HOME` produces the path valid on that host; restoring it with a remote execution world that has no shared mount produces no path. Published attachment objects use owner-read-only mode, including deduplicated objects, and model instructions require a writable copy for modifications. ## Alternatives considered **Persist the absolute path in `ImageAttachmentRef`.** A durable host path becomes stale after moving a session, changing `DSH_HOME`, or mounting another provider. Resolving it at request time preserves portable history. -**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns this fact. +**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns the object location; the shared LLM helper owns the composition with an execution-world mapper. + +**Let the attachment provider inspect `ctx.fs`.** The attachment provider knows where its object lives but does not own the model tools' execution environment. Combining both services in the LLM consumer also avoids a package reference cycle between attachment, filesystem, and LLM definitions. + +**Store `ImageAttachmentAccess` in `RequestImageAttachment`.** Request versions are deterministic cache and upload values. A path that changes with the host or execution environment must not affect their identity or appear to be part of their reproducible data. **Infer path sharing from the filesystem provider's package or class name.** Provider identity does not establish that a host file is mounted into its execution world. The filesystem provider instead answers the exact mapping question and can support an explicit shared mount without changing the attachment provider. @@ -32,8 +40,8 @@ Absolute paths stay out of session events. Model-visible path text is reconstruc ## Verification -Package tests cover provider access defaults, host-backed path mapping, absence without a mapped filesystem, digest-to-path resolution, owner-read-only publication and deduplication, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. +Package tests cover provider host-location defaults, host-backed path mapping, absence without a mapped filesystem, digest-to-path resolution, owner-read-only publication and deduplication, access passed independently from request versions, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. ## Consequences -When the execution world maps the attachment object, the selected model provider receives its path. This disclosure lets the model operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. Remote execution worlds without a shared mount receive the existing no-path recovery text. A missing local object still fails when a model tool attempts to read it. +When the execution world maps the attachment object, the selected model provider receives its path. This disclosure lets the model operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths can change while deterministic image bytes, `variantId`, and session references remain unchanged; the changed historical text can prevent KV-cache reuse from the first affected image even when no image is offloaded. Remote execution worlds without a shared mount receive the existing no-path recovery text. A missing local object still fails when a model tool attempts to read it. diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md index d67817cf69..2596dec5a2 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md @@ -1,4 +1,4 @@ -# Agent Note:在模型请求中提供由附件提供方解析的图片路径 +# Agent Note:在模型请求中提供执行环境图片路径 状态:已实现 @@ -10,21 +10,29 @@ ## 决策 -`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生对象的绝对路径,再要求当前 `ctx.fs` 把这个宿主文件映射到其执行世界。`FileSystem.processPathFromHostPath(hostPath)` 默认不返回映射。宿主后端返回绝对进程路径,E2B 和其他远程后端不返回访问事实。请求版本只在序列化期间携带当前可用的事实。 +`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageHostPath(ref)` 只公开附件提供方可选的宿主对象位置。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生该绝对路径,不检查模型工具的执行环境。 -共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及当前可用的提供方规范化路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 +每个 LLM 提供方插件在组装请求时提供桥接逻辑。它把附件存储与当前 `ctx.fs.processPathFromHostPath(hostPath)` 映射组合起来,再将得到的 `ImageAttachmentAccess` 单独传给保留图片序列化和 offload 占位内容。当前文件系统服务可以回答这个问题,因为挂载的文件系统和子进程提供方共享同一执行环境。另行检查子进程服务是否存在并不能证明路径可达。宿主文件系统返回进程路径;E2B 和其他没有共享挂载的远程后端不返回映射。 -请求大小 offload 要求提供逐图占位文本函数;原先共享的占位常量及其字节上限包装函数已没有生产调用方,因此删除。DeepSeek 和 pi-ai 会把每个省略位置替换为该图片自己的附件身份和当前访问事实,无需读取或转换省略对象。Offload 的选择、字节计量和按固定步长变化的前缀行为保持不变。 +`RequestImageAttachment` 仍是由附件和路由策略确定的请求版本。它包含 `variantId`、编码字节、尺寸和编码元数据,不包含访问路径。执行环境访问方式可能随宿主或挂载的提供方变化,不参与 `variantId`。 -描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。访问解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 +共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及桥接逻辑能够解析出的执行环境路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 -绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径;在没有共享挂载的远程执行世界中恢复时不会得到路径。发布的附件对象采用仅所有者可读的权限,去重对象也执行该权限。模型指令要求先复制到可写位置再修改。 +请求大小 offload 要求提供逐图占位文本函数;原先共享的占位常量及其字节上限包装函数已没有生产调用方,因此删除。DeepSeek 和 pi-ai 会把每个省略位置替换为该图片自己的附件身份和本次请求解析出的访问方式,无需读取或转换省略对象。Offload 的选择、字节计量和按固定步长变化的前缀行为保持不变。 + +描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。宿主位置解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 + +绝对路径不会进入会话事件或请求图片版本。模型可见路径根据已记录附件引用、当前附件提供方和当前执行环境挂载的文件系统重建。同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径;在没有共享挂载的远程执行环境中恢复时不会得到路径。发布的附件对象采用仅所有者可读的权限,去重对象也执行该权限。模型指令要求先复制到可写位置再修改。 ## 考虑过的替代方案 **把绝对路径持久保存到 `ImageAttachmentRef`。** 移动会话、更改 `DSH_HOME` 或挂载其他提供方后,持久宿主路径会失效。请求时解析可以保持历史可移植。 -**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。该事实属于附件提供方。 +**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。附件提供方拥有对象位置,共用 LLM 帮助函数负责与执行环境映射组合。 + +**让附件提供方检查 `ctx.fs`。** 附件提供方知道对象在哪里,但不拥有模型工具的执行环境。在 LLM 消费方组合两个服务,也避免了附件、文件系统和 LLM 定义包之间的项目引用循环。 + +**把 `ImageAttachmentAccess` 放进 `RequestImageAttachment`。** 请求版本是确定性的缓存和上传值。随宿主或执行环境变化的路径不能影响其身份,也不能表现为可重复生成的数据。 **根据文件系统提供方的包名或类名判断是否共享路径。** 提供方身份不能证明宿主文件已挂载到其执行世界。文件系统提供方直接回答具体路径能否映射,也允许未来的显式共享挂载在不修改附件提供方的情况下返回映射。 @@ -32,8 +40,8 @@ ## 验证 -包测试覆盖提供方访问默认值、宿主后端路径映射、没有文件系统映射时省略路径、本地摘要到路径的解析、仅所有者可读的发布与去重、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 +包测试覆盖提供方宿主位置默认值、宿主后端路径映射、没有文件系统映射时省略路径、本地摘要到路径的解析、仅所有者可读的发布与去重、独立于请求版本传入访问方式、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 ## 后果 -执行世界能够映射附件对象时,所选模型提供方会收到该路径。模型可借此操作存储图片,路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。没有共享挂载的远程执行世界会收到原有的无路径恢复文本。本地对象缺失时,模型工具读取它仍会失败。 +执行环境能够映射附件对象时,所选模型提供方会收到该路径。模型可借此操作存储图片,路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。路径变化时,确定性图片字节、`variantId` 和会话引用可以保持不变;即使没有图片被 offload,变化后的历史文本也可能使 KV 缓存从首张受影响图片起无法复用。没有共享挂载的远程执行环境会收到原有的无路径恢复文本。本地对象缺失时,模型工具读取它仍会失败。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c0ac258326..6b11f0e6ac 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: 7af4d4e47866736ca5f100d07dbd04f8341ae4a0 -config-catalog.zh.md: 9e1d3739d25b309172f2a9261cb10e8c5775af12 +config-catalog.md: 3b8b44211794a8334fd4cc32171c3fe33994eade +config-catalog.zh.md: e1554020282a454047716a11632e602916929b0b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7af4d4e478..3b8b442117 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -317,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) @@ -950,7 +950,7 @@ export interface DeepSeekCatalogModel { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:106`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:107`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9e1d3739d2..e155402028 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -319,7 +319,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) @@ -952,7 +952,7 @@ export interface DeepSeekCatalogModel { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:106`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:107`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index fc14d236c4..bbb4e4b38f 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: d855a31c4b463098e30652a30e807d648298c952 -attachment.zh.md: 314a6486266fe27d5e5fda17ae1b0c09ee65c5f3 +attachment.md: 7fafae424902632d8190e5d156b72795c31dcbbc +attachment.zh.md: 2eed9515a82fa8bba290a5d07f41e521dd7565d8 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index d855a31c4b..7fafae4249 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen ## Identity and verified metadata -`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. Consumers call `imageAccess()` when they need a path resolved by the mounted provider. +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path. ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -93,14 +93,6 @@ interface StoredImageAttachment { } ``` -```ts type-equiv -/** Provider-resolved ways for model tools to access one normalized attachment. */ -interface ImageAttachmentAccess { - /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ - readonlyPath: string -} -``` - ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -118,8 +110,6 @@ interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef - /** Transient provider access facts; never persisted in the durable reference. */ - access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -135,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageAccess()` resolves current-provider access facts without storing host paths in session data. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -187,12 +177,12 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Resolve provider-specific model-tool access without adding host facts to session history. + * Locate the provider-owned normalized object in the harness host filesystem. * @param ref - durable normalized attachment reference. - * @returns current-provider access facts, or undefined when this backend exposes no local path. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. * @throws an AttachmentError when the durable reference is invalid. */ -imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined +imageHostPath(ref: ImageAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 314a648626..2eed9515a8 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -10,7 +10,7 @@ ## 标识与经过校验的元数据 -`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方需要路径时调用 `imageAccess()`,由当前挂载的提供方负责解析。 +`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。 ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -93,14 +93,6 @@ interface StoredImageAttachment { } ``` -```ts type-equiv -/** Provider-resolved ways for model tools to access one normalized attachment. */ -interface ImageAttachmentAccess { - /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ - readonlyPath: string -} -``` - ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -118,8 +110,6 @@ interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef - /** Transient provider access facts; never persisted in the durable reference. */ - access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -135,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageAccess()` 解析当前提供方的访问信息,无需把宿主路径写入会话数据。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -187,12 +177,12 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Resolve provider-specific model-tool access without adding host facts to session history. + * Locate the provider-owned normalized object in the harness host filesystem. * @param ref - durable normalized attachment reference. - * @returns current-provider access facts, or undefined when this backend exposes no local path. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. * @throws an AttachmentError when the durable reference is invalid. */ -imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined +imageHostPath(ref: ImageAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index d3f211267a..173e05729d 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 4ad7af5673f3894d86af72b04db365fcc0f36608 -llm-streaming.zh.md: ff62bccdac018ea57b58d5edec9b7dae448be76a +llm-streaming.md: bdc830a5d387cde6967575551ec9b0a9b2626f46 +llm-streaming.zh.md: b602336bc06cd88a2634f5259eff117da3dcd986 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4ad7af5673..bdc830a5d3 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -30,6 +30,18 @@ interface ContentBlockMap { The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. +Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`. + +Source: [`packages/llm/llm/src/content.ts`](../../packages/llm/llm/src/content.ts) + +```ts type-equiv +/** Execution-world path that model tools can use to read one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + Source: [`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) A `Message` is one identified, immutable role/source/content value. Model-produced assistant messages name the provider and model that produced them and carry optional adapter-private replay data in their source: diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index ff62bccdac..b602336bc0 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -30,6 +30,18 @@ interface ContentBlockMap { 各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 +图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。 + +源码:[`packages/llm/llm/src/content.ts`](../../packages/llm/llm/src/content.ts) + +```ts type-equiv +/** Execution-world path that model tools can use to read one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + 源码:[`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) `Message` 是一个带标识且不可变的角色/来源/内容值。模型生成的 assistant 消息会在来源中记录生成它的提供方和模型,以及可选的适配器私有回放数据: diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index dd994eee03..4916c8ad2e 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-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/attachment/attachment-local/README.md -README.md: c93f32ede4599d8e07536062dd7d44134bea16b0 -README.zh.md: f06c3aeec937e0eb09ea0aa2ef25d41119e54aa8 +README.md: d9b6839c9b02fdfa4a0661ed44ae77e32e9a255e +README.zh.md: 11b4a6239e8b14ecfcc905e40a4cdc5042de5fcf diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index c93f32ede4..d9b6839c9b 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -8,15 +8,15 @@ Admission accepts at most 20 images and 200MiB of encoded source bytes per messa Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. -`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the normalized object's absolute host path and asks the mounted `ctx.fs` to map that file into its execution world. Host-backed filesystems return a read-only process path; remote filesystems without a shared mount return no path. The path is absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. `imageHostPath` derives the normalized object's absolute host path and does not inspect the tool execution world. At request assembly, an LLM consumer asks the mounted filesystem to map that host object into its execution world. A host-backed filesystem returns a process path; a remote filesystem without a shared mount returns no path. The mapped path is absent from durable history and from `RequestImageAttachment`. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. ## Model Experience -Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, available read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. +Indirectly, through request descriptors. When the current execution filesystem maps this backend's host object, the model receives each retained or offloaded image's identity, dimensions, media type, read-only mapped path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. #### KV Cache effect -Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns. +Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns. Execution-world path mapping is resolved separately and can change historical descriptor text without changing those bytes or their `variantId`. ## Known Limitations and Deferred Work diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index f06c3aeec9..11b4a6239e 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -8,15 +8,15 @@ 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 先派生规范化对象的绝对宿主路径,再要求当前 `ctx.fs` 把该文件映射到其执行世界。宿主文件系统返回只读进程路径;没有共享挂载的远程文件系统不返回路径。该路径不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。`imageHostPath` 派生规范化对象的绝对宿主路径,不检查工具执行环境。组装请求时,LLM 消费方要求当前文件系统把该宿主对象映射到其执行环境。宿主文件系统返回进程路径;没有共享挂载的远程文件系统不返回路径。映射后的路径不进入持久历史,也不进入 `RequestImageAttachment`。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 ## 模型体验 -该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前可用的只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 +该包通过请求描述间接影响模型。当前执行文件系统能够映射本后端的宿主对象时,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、映射后的只读路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 #### KV 缓存影响 -规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节。 +规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节。执行环境路径单独解析;它的映射变化会改变历史描述文本,但不会改变请求字节或 `variantId`。 ## 已知限制与待完成工作 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index cb2472f62a..194112be29 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -23,7 +23,6 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -34,8 +33,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 6a22038154..053fa69fc6 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -5,7 +5,6 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { - ImageAttachmentAccess, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, @@ -13,7 +12,6 @@ import type { SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import type {} from '@deepseek-ai/dsh-fs' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' @@ -209,10 +207,8 @@ export class LocalAttachmentStore extends AttachmentStore { return readImageFile(this.root, ref, signal) } - override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { - const hostPath = normalizedImagePath(this.root, ref) - const readonlyPath = this.ctx.get('fs')?.processPathFromHostPath(hostPath) - return readonlyPath === undefined ? undefined : { readonlyPath } + override imageHostPath(ref: ImageAttachmentRef): string { + return normalizedImagePath(this.root, ref) } override async readImageRequest( @@ -245,8 +241,7 @@ export class LocalAttachmentStore extends AttachmentStore { policy, sharedSignal, ) - const access = this.imageAccess(ref) - return { ...request, ...(access === undefined ? {} : { access }) } + return request })) operation = shared this.requestInflight.set(key, shared) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index ba18777c75..ef06768d55 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,6 +1,5 @@ import { Context } from '@deepseek-ai/cordis' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -46,8 +45,13 @@ describe('local attachment service', () => { width: 1, height: 1, } - expect(service.imageAccess(ref)).toBeUndefined() - expect(() => service.imageAccess({ ...ref, attachmentId: AttachmentId('invalid') })) + expect(service.imageHostPath(ref)).toBe(join( + service.root, + 'objects', + 'aa', + 'a'.repeat(64), + )) + expect(() => service.imageHostPath({ ...ref, attachmentId: AttachmentId('invalid') })) .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' })) }) @@ -61,20 +65,16 @@ describe('local attachment service', () => { it('saves and reads through the service boundary', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-')) - const ctx = new Context() - new LocalFileSystem(ctx, { cwd: dshHome, diffBasisMaxBytes: 10 * 1024 * 1024 }) try { - const service = new LocalAttachmentStore(ctx, { dshHome }) + const service = new LocalAttachmentStore(new Context(), { dshHome }) const data = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) - const access = service.imageAccess(ref) - expect(access).toBeDefined() - if (access === undefined) throw new Error('expected a host-path mapping from fs-local') - expect(access.readonlyPath).toBe(join( + const hostPath = service.imageHostPath(ref) + expect(hostPath).toBe(join( dshHome, 'attachments', 'v1', @@ -82,9 +82,9 @@ describe('local attachment service', () => { String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 2), String(ref.attachmentId).slice('sha256:'.length), )) - await expect(readFile(access.readonlyPath)).resolves.toEqual(Buffer.from(data)) + await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) - expect(request.access).toEqual(access) + expect(request).not.toHaveProperty('access') } finally { await rm(dshHome, { recursive: true, force: true }) } diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json index a5fff7a98a..3ac8b3fcff 100644 --- a/packages/attachment/attachment-local/tsconfig.json +++ b/packages/attachment/attachment-local/tsconfig.json @@ -6,7 +6,6 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../attachment" }, - { "path": "../../fs/fs" }, { "path": "../../util/home-paths" }, { "path": "../../runtime-diagnostics/invariants" } ] diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 1a55f27a2c..a2fb7e3452 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: 63c6a41fca3d0d7ea87d9f30e682e7e4e7aa5906 -README.zh.md: 4eb08dd169e1d0f2b20ada050513caaf88a84891 +README.md: 21030a492464aae528d4c06b4b72d4a94c0a2603 +README.zh.md: 0540996f99b3250331e567e174264cf7da8aa474 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 63c6a41fca..21030a4924 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,17 +4,17 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, local storage paths, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageAccess` resolves optional current-provider access facts, such as an absolute read-only path, without adding host-specific data to the durable reference. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageHostPath` optionally exposes the provider-owned object's absolute host path; it makes no claim that the current model tools can read that path. An LLM consumer combines this location with the mounted filesystem's execution-world mapping when it serializes a request. That current access path remains separate from the request version and its `variantId`. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. ## Model Experience -Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. A backend that offers model-tool access can also expose its current read-only normalized path; the descriptor states that normalization may have resized or re-encoded the upload. +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. When the attachment backend exposes a host object and the current execution filesystem maps it, the descriptor also exposes the resulting read-only path; it states that normalization may have resized or re-encoded the upload. #### KV Cache effect -Adding an image changes the provider request and therefore invalidates the affected request suffix. +Adding an image changes the provider request and therefore invalidates the affected request suffix. A changed execution-world path can also change historical descriptor text without changing the deterministic request version. ## Known Limitations and Deferred Work diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 4eb08dd169..0540996f99 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,17 +4,17 @@ 持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL、本地存储路径或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageAccess` 解析当前提供方可选的访问事实,例如绝对只读路径,同时避免把宿主信息加入持久引用。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageHostPath` 可以给出提供方所持对象的绝对宿主路径,但不保证当前模型工具能够读取它。LLM 消费方在序列化请求时将这个位置与当前文件系统提供的执行环境映射组合起来。解析出的访问路径独立于请求版本及其 `variantId`。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 ## 模型体验 -该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。支持模型工具访问的后端还可以公开当前规范化附件的只读路径;描述会说明规范化过程可能缩小或重新编码上传图片。 +该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。附件后端给出宿主对象且当前执行文件系统能够映射该对象时,描述还会公开映射后的只读路径,并说明规范化过程可能缩小或重新编码上传图片。 #### KV 缓存影响 -添加图片会改变提供方请求,因此会使受影响的请求后缀失效。 +添加图片会改变提供方请求,因此会使受影响的请求后缀失效。即使确定性的请求版本不变,执行环境路径变化也会改变历史描述文本。 ## 已知限制与待完成工作 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index f72b0b2052..ccfc3a3f98 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -4,7 +4,6 @@ import { Context, Service } from '@deepseek-ai/cordis' import { AttachmentError } from './error.ts' import type { ImageAttachmentLimits, - ImageAttachmentAccess, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, @@ -19,7 +18,6 @@ export { admitEncodedImages } from './admission.ts' export type { AttachmentId as AttachmentIdType, EncodedImageAttachment, - ImageAttachmentAccess, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, @@ -110,12 +108,12 @@ export abstract class AttachmentStore extends Service { abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Resolve provider-specific model-tool access without adding host facts to session history. + * Locate the provider-owned normalized object in the harness host filesystem. * @param ref - durable normalized attachment reference. - * @returns current-provider access facts, or undefined when this backend exposes no local path. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. * @throws an AttachmentError when the durable reference is invalid. */ - imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { + imageHostPath(ref: ImageAttachmentRef): string | undefined { void ref return undefined } diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 0b7d382b64..e23a7a7d4c 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -67,12 +67,6 @@ export interface StoredImageAttachment { data: Uint8Array } -/** Provider-resolved ways for model tools to access one normalized attachment. */ -export interface ImageAttachmentAccess { - /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ - readonlyPath: string -} - /** Deterministic request-image policy selected by one exact model route. */ export interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ @@ -87,8 +81,6 @@ export interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef - /** Transient provider access facts; never persisted in the durable reference. */ - access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index a28bcd6c1c..8675d45404 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -147,10 +147,10 @@ describe('AttachmentStore.readImageRequest', () => { expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) - it('exposes no provider-specific path by default', async () => { + it('exposes no provider-owned host path by default', async () => { const store = new RecordingStore(new Context()) const ref = await store.saveImage(image(1)) - expect(store.imageAccess(ref)).toBeUndefined() + expect(store.imageHostPath(ref)).toBeUndefined() }) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 57d9ab16d6..0798c8f31c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -450,10 +450,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, { - signature: 'imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined', - description: 'Resolve provider-specific model-tool access without adding host facts to session history.', + signature: 'imageHostPath(ref: ImageAttachmentRef): string | undefined', + description: 'Locate the provider-owned normalized object in the harness host filesystem.', parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }], - returns: 'current-provider access facts, or undefined when this backend exposes no local path.', + returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', throws: ['an AttachmentError when the durable reference is invalid.'], }, { @@ -3726,10 +3726,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GrantRecord', declaration: 'export interface GrantRecord {\n readonly kind: \'grant\';\n readonly payload: unknown;\n}', }, - { - name: 'ImageAttachmentAccess', - declaration: 'export interface ImageAttachmentAccess {\n readonlyPath: string;\n}', - }, { name: 'ImageAttachmentLimits', declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n maxImageDimension: number;\n mediaTypes: readonly ImageMediaType[];\n}', @@ -4236,7 +4232,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestImageAttachment', - declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n access?: ImageAttachmentAccess;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', + declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', }, { name: 'RequestRunOutcome', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index dc0fe9ccb6..75d595a147 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 31aa82dcc0dde68b7ed23edcd0dc477320d55505 -README.zh.md: dad1dbcea639130ff55430684d6478a86717154c +README.md: 629a1ebe00e5b14ac575a7b088a2e737e89d0c07 +README.zh.md: d5f11f8f2f8b9053685ba553a69fe71e40458ca3 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 31aa82dcc0..629a1ebe00 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -52,7 +52,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id, actual request dimensions, and the local normalized-object path when its attachment provider exposes one. The text marks that path read-only, gives the matching extension for a writable copy, and states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by text naming the complete attachment id and actual request dimensions. When the attachment provider exposes a host object and the current filesystem maps it into the tool execution world, the text also includes that read-only path and the matching extension for a writable copy. This access is resolved independently from the deterministic request version and its `variantId`. The descriptor states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. `maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Each removed image becomes its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only local path. This high-watermark projection avoids changing an old request prefix after every new image. @@ -115,7 +115,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles, request-preview dimensions, and the current normalized-object path when available; a Files resolution failure sends all retained images as inline data URLs instead. The descriptor tells the model that this read-only local copy may be resized or re-encoded and must not be used to infer upload properties. An over-budget older image keeps the same access facts in its per-image placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; a Files resolution failure sends all retained images as inline data URLs instead. The descriptor tells the model that this read-only local copy may be resized or re-encoded and must not be used to infer upload properties. An over-budget older image keeps the access currently resolved for that request in its per-image placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect @@ -123,7 +123,7 @@ Provider tokenization governs exact text and image-token input. Reasoning passba #### KV Cache effect -An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends on every reasoned turn. +An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. Deterministic request-image bytes do not make the complete prefix immutable: a changed execution-world path rewrites historical descriptor text even without offload, a refreshed upload can replace a `file_id`, and Files-to-base64 fallback changes the image representation. Any of these may prevent reuse from the first affected image. Model-route, prompt, schema, history, and image-budget changes have the same suffix effect; reasoning passback appends on every reasoned turn. ### DeepSeek response diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index dad1dbcea6..d5f11f8f2f 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -52,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及附件提供方支持时的本地规范化对象路径。文本会把该路径标记为只读,指出复制到可写路径时应使用的匹配扩展名,并说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有文本,写明完整附件 ID 和实际请求尺寸。附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,文本还会给出该只读路径,并指出复制到可写路径时应使用的匹配扩展名。该访问方式独立于确定性的请求版本及其 `variantId`。描述也会说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 `maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。每张被移除的图片都有自己的模型可见占位文本,其中包含显示名称或附件 ID;如果当前提供方支持,还会包含规范化尺寸、媒体类型和当前只读本地路径。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 @@ -115,7 +115,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄、请求预览尺寸,以及当前可用的规范化对象路径;Files 解析失败时,所有保留图片改用内联 data URL。描述会告诉模型,该本地副本只供读取,可能经过缩小或重新编码,不能据此推断上传图片的属性。超出上限的较旧图片会在自己的占位文本中保留相同的访问事实。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有附件句柄和请求预览尺寸。当前执行文件系统能够映射附件提供方的宿主对象时,模型还会收到规范化对象路径;Files 解析失败时,所有保留图片改用内联 data URL。描述会告诉模型,该本地副本只供读取,可能经过缩小或重新编码,不能据此推断上传图片的属性。超出上限的较旧图片会在自己的占位文本中保留本次请求解析出的访问方式。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 @@ -123,7 +123,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### KV Cache 影响 -未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在每个含推理的轮次上追加。 +未更改的已组装前缀可以使用 DeepSeek cache 复用,适配器会在 usage 中报告它。确定性的请求图片字节不能保证完整前缀不变:执行环境路径变化会在没有 offload 时改写历史描述,重新上传可能替换 `file_id`,Files 转为 base64 回退也会改变图片表示。这些变化都可能使复用从首张受影响图片起失效。模型路由、提示词、schema、历史和图片上限变化会产生同样的后缀影响;推理回传会在每个含推理的轮次上追加。 ### DeepSeek 响应 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index eba8fbc5a7..49f42e6539 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -57,6 +58,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8b4140a7d1..fccf6793d8 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -12,6 +12,7 @@ import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isCo import type { ContentBlock, GenerateOptions, + ImageAttachmentAccess, LlmModelInfo, LlmProviderInfo, PreparedAdapterCall, @@ -125,6 +126,8 @@ export interface DeepSeekAdapterOptions { resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ resolveAttachments?: () => AttachmentStore | undefined + /** Bridge one attachment reference into the current model-tool execution world. */ + resolveImageAccess?: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined /** Resolve the process-wide upload reuse store. */ resolveFiles?: () => DeepSeekFileStore /** Prepare the official API's plugin-contributed top-level fields for one exact wire request. */ @@ -538,6 +541,10 @@ export class DeepSeekAdapter extends LlmAdapter { const fileConnection = { baseURL: connection.baseURL, apiKey } const model = connection.models.find(entry => entry.id === options.model) const policy = model === undefined ? undefined : resolveRequestImagePolicy(model) + const resolveImageAccess = attachments === undefined + ? undefined + : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref) + const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess } const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { representation: 'raw', maxBytes: connection.maxRequestFilesBytes, @@ -545,7 +552,7 @@ export class DeepSeekAdapter extends LlmAdapter { byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, byteLength: ref => Math.min(ref.bytes, policy.maxBytes), - placeholder: ref => offloadedImageText(ref, attachments?.imageAccess(ref)), + placeholder: ref => offloadedImageText(ref, resolveImageAccess?.(ref)), }) const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } const requestImages = attachments === undefined || model === undefined @@ -562,6 +569,7 @@ export class DeepSeekAdapter extends LlmAdapter { body = await serializeRequestWithImages(requestOptions, { representation: { kind: 'base64' }, requestImages, + ...imageAccessOptions, maxRequestImageBytes: connection.maxInlineRequestImageBytes, maxImagesPerRequest: connection.maxImagesPerRequest, byteQuantum: connection.inlineImageOffloadByteQuantum, @@ -592,6 +600,7 @@ export class DeepSeekAdapter extends LlmAdapter { }, }, requestImages, + ...imageAccessOptions, maxRequestImageBytes: connection.maxRequestFilesBytes, maxImagesPerRequest: connection.maxImagesPerRequest, byteQuantum: connection.imageOffloadByteQuantum, diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 4f5864aa6d..0d895a5e3b 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -13,8 +13,9 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-fs' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { launchEnvironmentOf, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -438,6 +439,11 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey, resolveUserId, resolveAttachments: () => ctx.get('attachments'), + resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( + attachments, + hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), + ref, + ), prepareExtensions: (request) => { const extensions = ctx.get('deepseekLlmApiExtensions') return extensions?.prepare(request) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index e1218ba16c..1749991eca 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -7,7 +7,7 @@ */ import { contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { WireImageContentPart, @@ -48,6 +48,8 @@ export interface ImageSerializationOptions { representation: ImageRequestRepresentation /** Request versions prepared for the conservatively retained normalized attachments, keyed by attachment id. */ requestImages: ReadonlyMap + /** Resolve current tool access independently from deterministic request-image versions. */ + resolveImageAccess?: ImageAttachmentAccessResolver /** Positive bound on accumulated represented image bytes. */ maxRequestImageBytes: number /** Maximum represented images in one request. */ @@ -127,11 +129,12 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { function imageHandle( ref: ImageAttachmentRef, version: RequestImageAttachment, + resolveAccess: ImageAttachmentAccessResolver | undefined, precededByContent: boolean, ): WireTextContentPart { return { type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version)}`, + text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version, resolveAccess?.(ref))}`, } } @@ -155,7 +158,7 @@ async function imageParts( type: 'image_url', image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` }, } - return [imageHandle(block.attachment, version, precededByContent), image] + return [imageHandle(block.attachment, version, images.resolveImageAccess, precededByContent), image] } /** Convert user or nested tool-result blocks into ordered wire parts. */ @@ -390,10 +393,10 @@ export function serializeRequest( /** * Build one image-capable request while keeping durable bytes out of session - * messages. Oversized oldest images become deterministic text after their + * messages. Oversized oldest images become per-image text after their * exact request-version byte lengths are known and before provider serialization. * @param options - harness request containing image-capable user content. - * @param images - attachment resolver, request bound, and cancellation. + * @param images - request versions, optional current access resolver, and request bounds. * @param defaults - adapter-level thinking defaults. * @returns the fully materialized DeepSeek request body. */ @@ -416,10 +419,7 @@ export async function serializeRequestWithImages( ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, - placeholder: (ref) => { - const version = images.requestImages.get(ref.attachmentId) - return offloadedImageText(ref, version?.access) - }, + placeholder: ref => offloadedImageText(ref, images.resolveImageAccess?.(ref)), }) const messages: WireMessage[] = [] if (options.system !== undefined) { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 362a90dadb..9366c32ce8 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -109,7 +109,7 @@ function attachmentStoreOf( } { const readImageRequest = vi.fn(project) return { - store: { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore, + store: { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore, readImageRequest, } } diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index cb94b3c237..0240a6c22a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -57,7 +57,7 @@ function imageOptions( refs: readonly ImageAttachmentRef[], resolveFileId: FileResolver = fileResolver(), maxRequestImageBytes = 20 * 1024 * 1024, -) { +): ImageSerializationOptions { return { representation: { kind: 'file' as const, resolveFileId }, requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), @@ -426,7 +426,7 @@ describe('image serialization', () => { const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment version.width = 1130 version.height = 565 - version.access = { readonlyPath: '/tmp/dsh/objects/aa/object' } + images.resolveImageAccess = () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }) const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ @@ -594,8 +594,9 @@ describe('image serialization', () => { const png = imageRef('image/png', 3) const jpeg = imageRef('image/jpeg', 3) const images = imageOptions([png, jpeg], resolveFileId, 4) - const pngVersion = images.requestImages.get(png.attachmentId) as RequestImageAttachment - pngVersion.access = { readonlyPath: '/tmp/dsh/objects/png' } + images.resolveImageAccess = ref => ref.mediaType === 'image/png' + ? { readonlyPath: '/tmp/dsh/objects/png' } + : undefined const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 2f75c10b9e..81b7bb1eca 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../fs/fs" + }, { "path": "../../util/atomic-write" }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 67377a2daa..6121212098 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 7f0c2a32315200e3244cb83d396bbf2ee30d728f -README.zh.md: f11d4ba1d991cc70c1967eef8f8b880727df36ce +README.md: 1ae0e641f7ffaebc1c2c1060e143c72a40631396 +README.zh.md: 17bf059ea9bb3fbbb80cadc0c9eefed76af26abd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 7f0c2a3231..1ae0e641f7 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id, actual request-image dimensions, and provider-resolved normalized-object path when available. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64. Its descriptor exposes the attachment id and actual request-image dimensions, plus a normalized-object path only when the attachment provider exposes a host object and the current filesystem maps it into the tool execution world. The path is resolved separately from the request version and its `variantId`. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id, actual request dimensions, and current normalized-object path when available. The descriptor marks that path read-only and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and available local path in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. The text includes a normalized-object path when the current execution filesystem maps the attachment provider's host object, marks that path read-only, and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and access currently resolved for that request in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect @@ -181,7 +181,7 @@ Provider tokenization governs exact input. Retained images add the stable attach #### KV Cache effect -Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. Crossing the image bound rewrites an early message (the newly offloaded image becomes placeholder text), so reuse ends at that message until the offloaded prefix stabilizes. +Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. Stable attachment identity and request bytes do not make that text immutable: a changed execution-world path rewrites a historical handle even without offload and may prevent reuse from that image. Changing adapter instance, provider, model, or any other upstream request token has the same suffix effect. Crossing the image bound replaces an earlier image with placeholder text, so reuse ends at that message until the offloaded prefix stabilizes. ### Provider response diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f11d4ba1d9..17bf059ea9 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID、实际请求图片尺寸,以及当前提供方支持时的规范化对象路径。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64。对应描述会公开附件 ID 和实际请求图片尺寸;只有附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,描述才会加入规范化对象路径。该路径独立于请求版本及其 `variantId`。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及当前可用的规范化对象路径。描述会把该路径标记为只读,并说明规范化或请求投影可能缩小或重新编码上传图片。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张被 offload 的图片会在替代文本中保留自己的身份和可用本地路径。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有文本,写明完整附件 ID 和实际请求尺寸。当前执行文件系统能够映射附件提供方的宿主对象时,文本还会给出规范化对象路径,将其标记为只读,并说明规范化或请求投影可能缩小或重新编码上传图片。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张被 offload 的图片会在替代文本中保留自己的身份和本次请求解析出的访问方式。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 @@ -182,7 +182,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### KV Cache 影响 -转换保留逻辑请求顺序,不添加文本;复用取决于所选提供方的序列化与回放状态。更改适配器实例、提供方、模型或任何上游请求 token,都可能使复用从首个出现差异的 token 起失效。跨过图片上限会改写较早的一条消息(新被 offload 的图片变为占位文本),复用在该消息处截止,直到被 offload 的前缀稳定。 +转换保留逻辑请求顺序,图片句柄和 offload 占位内容会加入模型可见文本。稳定的附件身份和请求字节不能保证这些文本不变:执行环境路径变化会在没有 offload 时改写历史句柄,并可能使复用从该图片起失效。更改适配器实例、提供方、模型或其他上游请求 token 会产生同样的后缀影响。跨过图片上限会把较早图片替换为占位文本,复用在该消息处截止,直到被 offload 的前缀稳定。 ### 提供方响应 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index d0755b06cc..ce809946be 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 37d4ea48f6..e20b8e0072 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -47,6 +47,7 @@ import { } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, + ImageAttachmentAccess, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, @@ -55,7 +56,7 @@ import type { ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' @@ -93,6 +94,8 @@ export interface PiAiAdapterOptions { auth: PiAiAuthInjection /** Resolve the optional durable attachment service at request time. */ resolveAttachments?: () => AttachmentStore | undefined + /** Bridge one attachment reference into the current model-tool execution world. */ + resolveImageAccess?: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined /** * Observe one assistant history message degrading to provider-neutral * conversion because its stored replay state is unusable by this build. @@ -360,10 +363,15 @@ export class PiAiAdapter extends LlmAdapter { } const context = attachments === undefined ? toPiContext(options, undefined, onReplayDegrade) - : await toPiContext({ ...options, signal: watchdog.signal }, attachments, onReplayDegrade, profile.maxRequestImageBytes, { - maxPixels: profile.requestImagePixelBudget, - maxBytes: profile.requestImageMaxBytes, - }) + : await toPiContext({ ...options, signal: watchdog.signal }, { + attachments, + resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref), + maxRequestImageBytes: profile.maxRequestImageBytes, + requestImagePolicy: { + maxPixels: profile.requestImagePixelBudget, + maxBytes: profile.requestImageMaxBytes, + }, + }, onReplayDegrade) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index db99f86d8e..4315cc690e 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -5,7 +5,7 @@ */ import { CallId, contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentId, AttachmentStore, @@ -48,6 +48,7 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { async function userContent( blocks: readonly ContentBlock[], requestImages: ReadonlyMap, + resolveImageAccess: ImageAttachmentAccessResolver, ): Promise { const content: (TextContent | ImageContent)[] = [] for (const block of blocks) { @@ -57,7 +58,10 @@ async function userContent( break case 'image': { const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment - content.push({ type: 'text', text: requestImageHandleText(block.attachment, version) }) + content.push({ + type: 'text', + text: requestImageHandleText(block.attachment, version, resolveImageAccess(block.attachment)), + }) content.push({ type: 'image', data: Buffer.from(version.data).toString('base64'), @@ -67,7 +71,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(block.content, requestImages) + const nested = await userContent(block.content, requestImages, resolveImageAccess) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -170,17 +174,29 @@ function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: st return piContext(options, messages) } +/** Inputs that bind deterministic request images to one current tool execution world. */ +export interface PiImageRequestContext { + /** Durable provider that resolves request-image bytes and provider-owned host objects. */ + attachments: AttachmentStore + /** Resolve current tool access separately from deterministic request-image versions. */ + resolveImageAccess: ImageAttachmentAccessResolver + /** Request-level bound on base64-encoded image payload; omission leaves every image in place. */ + maxRequestImageBytes?: number + /** Route pixel and raw encoded-byte budgets. */ + requestImagePolicy?: ImageRequestPolicy +} + /** * Convert text-only harness history to a synchronous pi-ai Context. Tool * result names are recovered from preceding assistant tool calls. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @param attachments - absent; selects the synchronous conversion. + * @param images - absent; selects the synchronous conversion. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. * @returns the pi-ai context; `tools` is omitted when the request declares none. */ export function toPiContext( options: GenerateOptions, - attachments?: undefined, + images?: undefined, onReplayDegrade?: (reason: string) => void, ): PiContext /** @@ -190,48 +206,42 @@ export function toPiContext( * oldest images are replaced by text placeholders until the request fits, so * an image-heavy session keeps clearing gateway request-size caps. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @param attachments - durable byte resolver for image references. + * @param images - attachment provider, current path resolver, and request limits. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. - * @param maxRequestImageBytes - request-level bound on base64-encoded image payload; omission leaves every image in place. - * @param requestImagePolicy - route pixel and raw encoded-byte budgets. * @returns the asynchronously resolved pi-ai context. */ export function toPiContext( options: GenerateOptions, - attachments: AttachmentStore, + images: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy?: ImageRequestPolicy, ): Promise export function toPiContext( options: GenerateOptions, - attachments?: AttachmentStore, + images?: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy?: ImageRequestPolicy, ): PiContext | Promise { - return attachments === undefined + return images === undefined ? textOnlyContext(options, onReplayDegrade) - : toPiContextWithImages(options, attachments, onReplayDegrade, maxRequestImageBytes, requestImagePolicy) + : toPiContextWithImages(options, images, onReplayDegrade) } async function toPiContextWithImages( options: GenerateOptions, - attachments: AttachmentStore, + images: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy: ImageRequestPolicy = { +): Promise { + const { attachments, resolveImageAccess, maxRequestImageBytes } = images + const requestImagePolicy = images.requestImagePolicy ?? { maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, - }, -): Promise { + } assertSupportedImageRoles(options.messages) const requestMessages = offloadRequestImagesWithPolicy(options.messages, { representation: 'base64', ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => Math.min(ref.bytes, requestImagePolicy.maxBytes), - placeholder: ref => offloadedImageText(ref, attachments.imageAccess(ref)), + placeholder: ref => offloadedImageText(ref, resolveImageAccess(ref)), }) const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal) const exactMessages = offloadRequestImagesWithPolicy(requestMessages, { @@ -239,7 +249,7 @@ async function toPiContextWithImages( ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes, - placeholder: ref => offloadedImageText(ref, requestImages.get(ref.attachmentId)?.access), + placeholder: ref => offloadedImageText(ref, resolveImageAccess(ref)), }) const toolNames = new Map() const messages: PiMessage[] = [] @@ -262,7 +272,7 @@ async function toPiContextWithImages( } // user role: text + tool results (each result becomes its own message). const regular = message.content.filter(block => block.type !== 'tool-result') - const content = await userContent(regular, requestImages) + const content = await userContent(regular, requestImages, resolveImageAccess) const results = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) @@ -270,7 +280,7 @@ async function toPiContextWithImages( messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { - const resultContent = await userContent(result.content, requestImages) + const resultContent = await userContent(result.content, requestImages, resolveImageAccess) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 846509f8c6..c9752b764e 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -57,8 +57,9 @@ import type { Context } from '@deepseek-ai/cordis' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-fs' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { authContextFrom, credentialStoreFrom } from './auth.ts' @@ -197,6 +198,11 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey, auth, resolveAttachments: () => ctx.get('attachments'), + resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( + attachments, + hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), + ref, + ), onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn( `llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}";` diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index a971b80c85..4e6e29c118 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -9,6 +9,7 @@ import type { import { CallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { toPiContext } from '../src/context.ts' +import type { PiImageRequestContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' const ref: ImageAttachmentRef = { @@ -43,11 +44,18 @@ function projectionStore( Promise.resolve(requestImage(value, Uint8Array.of(1))) )), ): AttachmentStore { - return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore + return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore } const attachments = projectionStore() +function imageContext( + store: AttachmentStore, + overrides: Partial> = {}, +): PiImageRequestContext { + return { attachments: store, resolveImageAccess: () => undefined, ...overrides } +} + function request(messages: GenerateOptions['messages']): GenerateOptions { return { provider: 'openai', @@ -138,7 +146,7 @@ describe('pi-ai request context conversion', () => { { type: 'image', attachment: ref }, ], }]), - ]), attachments) + ]), imageContext(attachments)) expect(context.messages).toEqual([ { role: 'user', content: '', timestamp: 0 }, @@ -180,9 +188,10 @@ describe('pi-ai request context conversion', () => { ...requestImage(value, Uint8Array.of(1)), width: 1130, height: 565, - access: { readonlyPath: '/tmp/dsh/objects/aa/object' }, })) - const context = await toPiContext(request([user([{ type: 'image', attachment: named }])]), store) + const context = await toPiContext(request([user([{ type: 'image', attachment: named }])]), imageContext(store, { + resolveImageAccess: () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }), + })) expect(context.messages[0]).toMatchObject({ role: 'user', content: [ @@ -211,7 +220,7 @@ describe('pi-ai request context conversion', () => { content: [{ type: 'image', attachment: ref }], }, ], - }])]), attachments) + }])]), imageContext(attachments)) expect(context.messages).toEqual([{ role: 'toolResult', @@ -265,7 +274,7 @@ describe('pi-ai request context conversion', () => { }]), user([{ type: 'image', attachment: sized }, { type: 'text', text: 'newer' }]), user([{ type: 'image', attachment: sized }]), - ]), store, undefined, 8) + ]), imageContext(store, { maxRequestImageBytes: 8 })) expect(context.messages).toEqual([ { @@ -308,7 +317,7 @@ describe('pi-ai request context conversion', () => { const context = await toPiContext(request([user([ { type: 'image', attachment: old }, { type: 'image', attachment: recent }, - ])]), projectionStore(readImageRequest), undefined, 4) + ])]), imageContext(projectionStore(readImageRequest), { maxRequestImageBytes: 4 })) expect(context.messages[0]).toMatchObject({ role: 'user', @@ -322,17 +331,19 @@ describe('pi-ai request context conversion', () => { expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent) }) - it('uses the generated request access when exact encoded bytes require offload', async () => { + it('uses independently resolved access when exact encoded bytes require offload', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const access = { readonlyPath: '/tmp/dsh-normalized-image' } const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve({ ...requestImage(value, Uint8Array.of(1, 2, 3, 4)), - access, })) const context = await toPiContext(request([ user([{ type: 'image', attachment: sized }]), - ]), projectionStore(readImageRequest), undefined, 4) + ]), imageContext(projectionStore(readImageRequest), { + maxRequestImageBytes: 4, + resolveImageAccess: () => access, + })) expect(context.messages).toEqual([{ role: 'user', @@ -347,7 +358,7 @@ describe('pi-ai request context conversion', () => { const exact = await toPiContext(request([ user([{ type: 'image', attachment: sized }]), user([{ type: 'image', attachment: sized }]), - ]), attachments, undefined, 8) + ]), imageContext(attachments, { maxRequestImageBytes: 8 })) expect(exact.messages).toEqual([ { role: 'user', @@ -367,7 +378,7 @@ describe('pi-ai request context conversion', () => { const store = projectionStore(readImageRequest) const oversized = await toPiContext(request([ user([{ type: 'image', attachment: { ...ref, bytes: 300 } }]), - ]), store, undefined, 8) + ]), imageContext(store, { maxRequestImageBytes: 8 })) // All-text content collapses to the string form; the placeholder still reaches the model. expect(oversized.messages).toEqual([ { role: 'user', content: offloadedImageText({ ...ref, bytes: 300 }), timestamp: 0 }, @@ -382,11 +393,14 @@ describe('pi-ai request context conversion', () => { Promise.resolve(requestImage(value, Uint8Array.of(1, 2, 3))) )) const store = projectionStore(readImageRequest) - const aliased = await toPiContext(request([user([shared, shared])]), store, undefined, 4) + const aliased = await toPiContext( + request([user([shared, shared])]), + imageContext(store, { maxRequestImageBytes: 4 }), + ) const replayed = await toPiContext(request([user([ { type: 'image', attachment: { ...sized } }, { type: 'image', attachment: { ...sized } }, - ])]), store, undefined, 4) + ])]), imageContext(store, { maxRequestImageBytes: 4 })) const expected = [{ role: 'user', @@ -430,7 +444,7 @@ describe('pi-ai request context conversion', () => { const store = projectionStore(readImageRequest) await expect(toPiContext(request([ history(role, [{ type: 'image', attachment: ref }]), - ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + ]), imageContext(store, { maxRequestImageBytes: 1 }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) expect(readImageRequest).not.toHaveBeenCalled() } @@ -438,7 +452,7 @@ describe('pi-ai request context conversion', () => { history('system', [{ type: 'text', text: 'history system' }]), history('assistant', [{ type: 'text', text: 'answer' }]), user([{ type: 'text', text: 'plain' }]), - ]), attachments)).resolves.toMatchObject({ + ]), imageContext(attachments))).resolves.toMatchObject({ messages: [ { role: 'user', content: 'history system' }, { role: 'assistant' }, diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 1d53dbdab0..6967ea8fd6 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -63,7 +63,11 @@ function attachmentStore(readImageRequest: ( policy: ImageRequestPolicy, signal?: AbortSignal, ) => Promise): AttachmentStore { - return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore + return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore +} + +function imageContext(attachments: AttachmentStore) { + return { attachments, resolveImageAccess: () => undefined } } describe('toPiContext', () => { @@ -109,7 +113,7 @@ describe('toPiContext', () => { content: [{ type: 'text', text: 'describe' }, { type: 'image', attachment }], source: { kind: 'plugin', plugin: 'test' }, })], - }, attachmentStore(readImageRequest)) + }, imageContext(attachmentStore(readImageRequest))) expect(readImageRequest).toHaveBeenCalledWith( attachment, @@ -161,7 +165,7 @@ describe('toPiContext', () => { }], source: { kind: 'plugin', plugin: 'test' }, })], - }, attachmentStore(readImageRequest)) + }, imageContext(attachmentStore(readImageRequest))) expect(context.messages).toEqual([{ role: 'toolResult', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 172dbe8a6e..8200210b6d 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../fs/fs" + }, { "path": "../../credentials/credentials" }, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 3ac89ec262..bec2fe9b81 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: d08d28497c4821c578fac43515b3bebfa4ed8131 -README.zh.md: 3d7a183aa2319acd869d8e4bc68ec9493c3ea322 +README.md: ef58516790a2723bce34aa1bbae2e1629050f6a5 +README.zh.md: 8af1240cdb4f3b65f1c0f841ade620c85443129b diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d08d28497c..ef58516790 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -57,7 +57,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o Message content is an array of typed blocks: `text`, `reasoning`, `image`, `tool-call`, `tool-result`. An `ImageBlock` carries only a durable `ImageAttachmentRef`; provider bytes and request dimensions are resolved later. The union remains merge-extensible through `ContentBlockMap`, so plugins can add further block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. -Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text. +Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. `resolveImageAttachmentAccess()` separately combines an attachment provider's optional host object with a consumer-supplied mapping from that host path into the current tool execution world. The result never enters `RequestImageAttachment` or its `variantId`. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content. @@ -95,7 +95,7 @@ None, as adapters choose when to add the shared image descriptors and per-image #### KV Cache effect -Reasoning-effort materialization preserves the assembled request prefix. Image descriptors add deterministic text beside each image, and a request-limit transition replaces the deterministic oldest prefix with per-image text. +Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while the optional execution-world path is resolved for each request. A changed path can alter a historical descriptor and prevent reuse from that image even without offload. Crossing a request limit also replaces an older image with per-image text. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 3d7a183aa2..8af1240cdb 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -57,7 +57,7 @@ 消息内容是类型化内容块数组:`text`、`reasoning`、`image`、`tool-call`、`tool-result`。`ImageBlock` 只携带持久 `ImageAttachmentRef`;提供方字节和请求尺寸之后再解析。联合仍从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加其他块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型或提供方间恢复或转换该状态。 -每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。 +每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。`resolveImageAttachmentAccess()` 单独组合附件提供方可选的宿主对象,以及消费方给出的宿主路径到当前工具执行环境的映射。解析结果不进入 `RequestImageAttachment` 或其 `variantId`。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。 流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。 @@ -97,7 +97,7 @@ #### KV Cache 影响 -推理强度填入不会改变已组装的请求前缀。图片描述会在每张图片旁加入确定性文本;请求越过上限时,确定性的最旧前缀会替换为逐图文本。 +推理强度填入不会改变已组装的请求前缀。图片身份和请求预览文本具有确定性,可选的执行环境路径则按请求解析。路径变化会改写历史图片描述,即使没有 offload,也可能使缓存从该图片起无法复用。请求越过上限时,较旧图片也会替换为逐图文本。 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 10076ad529..ed97a9b6f1 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,9 +2,43 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { ImageAttachmentAccess, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { assertNever } from './never.ts' +/** Execution-world path that model tools can use to read one normalized attachment. */ +export interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} + +/** + * Resolve current execution-world access for one durable image reference. + * @param ref - durable normalized attachment reference. + * @returns a read-only execution-world path, or undefined when unavailable. + */ +export type ImageAttachmentAccessResolver = (ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined + +/** + * Bridge one attachment provider's host object location into the mounted + * tool execution world. The consumer supplies the current filesystem + * provider's mapping without making attachment or LLM definitions depend on it. + * @param attachments - provider that owns the normalized attachment object. + * @param mapHostPath - map one absolute host path into the current tool execution world. + * @param ref - durable normalized attachment reference. + * @returns a read-only execution-world path, or undefined when either provider exposes no mapping. + * @throws an attachment error when the durable reference is invalid. + */ +export function resolveImageAttachmentAccess( + attachments: AttachmentStore, + mapHostPath: (hostPath: string) => string | undefined, + ref: ImageAttachmentRef, +): ImageAttachmentAccess | undefined { + const hostPath = attachments.imageHostPath(ref) + if (hostPath === undefined) return undefined + const readonlyPath = mapHostPath(hostPath) + return readonlyPath === undefined ? undefined : { readonlyPath } +} + function quoted(value: string): string { return JSON.stringify(value) } @@ -48,13 +82,18 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { * names differ. * @param ref - the occurrence's durable normalized attachment. * @param version - exact request image shown beside the text. + * @param access - optional path resolved for the current tool execution world. * @returns attachment handle and request-image dimensions. */ -export function requestImageHandleText(ref: ImageAttachmentRef, version: RequestImageAttachment): string { +export function requestImageHandleText( + ref: ImageAttachmentRef, + version: RequestImageAttachment, + access?: ImageAttachmentAccess, +): string { const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.` - return version.access === undefined + return access === undefined ? `${preview} It may be resized or re-encoded; source dimensions, format, and byte size may differ.` - : preview + normalizedAccessText(ref, version.access) + : preview + normalizedAccessText(ref, access) } /** diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 236cee2297..3391423bdb 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { CallId, createUserMessage, offloadedImageText, offloadRequestImagesWithPolicy, projectImagesForTextModel, + resolveImageAttachmentAccess, requestImageHandleText, } from '../src/index.ts' import type { ContentBlock, Message } from '../src/index.ts' @@ -186,10 +187,10 @@ describe('model-facing image access', () => { height: 1536, name: 'source "map".png', } + const access = { readonlyPath: '/tmp/.dsh/attachments/v1/objects/bb/object' } const version = { variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`), attachment, - access: { readonlyPath: '/tmp/.dsh/attachments/v1/objects/bb/object' }, data: Uint8Array.of(1), mediaType: 'image/png' as const, bytes: 1, @@ -199,7 +200,7 @@ describe('model-facing image access', () => { space: 'srgb' as const, hasAlpha: true, } - expect(requestImageHandleText(attachment, version)).toBe( + expect(requestImageHandleText(attachment, version, access)).toBe( `Image "source \\"map\\".png" (${attachment.attachmentId}); request preview 923x692px.` + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/.dsh/attachments/v1/objects/bb/object" (2048x1536px, image/png).' + ' Source dimensions, format, and byte size may differ.' @@ -207,6 +208,31 @@ describe('model-facing image access', () => { ) }) + it('bridges a provider host object only through the mounted filesystem mapping', () => { + const attachment = image(1).attachment + const attachments = { + imageHostPath: () => '/host/.dsh/attachments/object', + } as unknown as AttachmentStore + const mapped = (hostPath: string): string | undefined => hostPath === '/host/.dsh/attachments/object' + ? '/workspace/.attachments/object' + : undefined + expect(resolveImageAttachmentAccess( + attachments, + mapped, + attachment, + )).toEqual({ readonlyPath: '/workspace/.attachments/object' }) + expect(resolveImageAttachmentAccess( + attachments, + () => undefined, + attachment, + )).toBeUndefined() + expect(resolveImageAttachmentAccess( + { imageHostPath: () => undefined } as unknown as AttachmentStore, + mapped, + attachment, + )).toBeUndefined() + }) + it('names each occurrence from its own reference when one prepared version is shared', () => { const attachment = { attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eebeb12111..9aef169a41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1136,12 +1136,6 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../../fs/fs - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths @@ -6189,6 +6183,9 @@ importers: '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:^ version: link:../deepseek-llm-api-extensions + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths @@ -6238,6 +6235,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 31217c1f31..db52420840 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -335,7 +335,7 @@ export const LINK_MAP: Readonly> = { ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', EncodedImageAttachment: 'attachment.md', - ImageAttachmentAccess: 'attachment.md', + ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', RequestImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0195425c04..3bedaa9804 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -36,6 +36,11 @@ "symbol": "ContextFormed", "source": "packages/llm/llm/src/message.ts" }, + { + "doc": "docs/subsystems/llm-streaming.md", + "symbol": "ImageAttachmentAccess", + "source": "packages/llm/llm/src/content.ts" + }, { "doc": "docs/subsystems/llm-streaming.md", "symbol": "FinishReasonMap", @@ -931,11 +936,6 @@ "symbol": "StoredImageAttachment", "source": "packages/attachment/attachment/src/types.ts" }, - { - "doc": "docs/subsystems/attachment.md", - "symbol": "ImageAttachmentAccess", - "source": "packages/attachment/attachment/src/types.ts" - }, { "doc": "docs/subsystems/attachment.md", "symbol": "ImageRequestPolicy", From 65a8d6be1be1f422286673cdc46be6b2096e7959 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 16:34:42 +0800 Subject: [PATCH 038/138] feat(client): show build version in local banner --- ...6-08-18-client-build-environment.i18n.yaml | 4 +- .../2026-08-18-client-build-environment.md | 8 +- .../2026-08-18-client-build-environment.zh.md | 8 +- apps/web/tests/built-boot.snapshot.ts | 42 ++++++- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 2 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 22 +++- .../sidebar-snapshot.client.spec.tsx.snap | 12 +- .../tests/sidebar-root.client.spec.tsx | 35 +++++- .../tests/sidebar-snapshot.client.spec.tsx | 6 +- scripts/build.ts | 13 +- .../client-build-environment.client.spec.ts | 114 ++++++++++++++++++ scripts/client-build-environment.ts | 86 ++++++++++++- scripts/dev-web.spec.ts | 37 +++++- scripts/dev-web.ts | 28 +++++ 20 files changed, 389 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml index b97fb2d1e8..e778ec3085 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.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-18-client-build-environment.md -2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431 -2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328 +2026-08-18-client-build-environment.md: 590f53521476b5195acbad73c452fac3b2f8f914 +2026-08-18-client-build-environment.zh.md: 8803463663d3d3bd08f5abdbb6f54ed3795fe6a8 diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md index 45ed6c8bc6..590f535214 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -18,7 +18,9 @@ The Vite config and the shared tsdown preset for dynamic client bundles use one The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it. -The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. +The root build wrapper supplies one exact public environment to both bundlers. Every complete build carries the root package version as `DSH_CLIENT_VERSION` and the seven-character source Git HEAD prefix as `DSH_CLIENT_COMMIT_HASH`; an explicit commit supports build environments without repository metadata. A default local build also samples Git status before building and sets `DSH_CLIENT_GIT_DIRTY=true` for any staged, unstaged, untracked, or submodule change. Clean checkouts and sources without Git metadata omit the dirty field. These repository-owned fields replace inherited values, while `pnpm run build` otherwise inherits the caller's remaining `DSH_CLIENT_*` values. + +`pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax. Its exact environment carries the version and commit, sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations, and omits local dirty metadata. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. `pnpm run dev:web` verifies this record before its watcher stages start and reuses its public environment for the lifetime of every stage; it does not resample repository state during incremental rebuilds. ## Alternatives considered @@ -30,8 +32,10 @@ The root build wrapper supplies one exact public environment to both bundlers. I **Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path. +**Resample Git state during every watcher rebuild.** Vite and tsdown fix define substitutions when their long-lived watchers start. Restarting the build pipeline on repository status changes would make an incremental source edit rebuild unrelated artifacts; retaining the last complete build record gives every watcher stage one consistent metadata snapshot. + ## Consequences -The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. +The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its package version and short source revision as public display metadata; a dirty default build additionally identifies its uncommitted source state. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. Watch builds retain their startup snapshot until another complete build replaces the record. Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism. diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md index bb96337214..8803463663 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -18,7 +18,9 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define `DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。 -根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。 +根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都以 `DSH_CLIENT_VERSION` 携带根包版本,并以 `DSH_CLIENT_COMMIT_HASH` 携带源码 Git HEAD 的七位前缀;没有仓库元数据的构建环境可显式提供 commit。默认本地构建还会在构建前读取 Git 状态;存在任何暂存、未暂存、未跟踪或子模块变化时设置 `DSH_CLIENT_GIT_DIRTY=true`。没有变化的 worktree 和没有 Git 元数据的源码不携带 dirty 字段。这些由仓库持有的字段会替换继承值,除此之外,`pnpm run build` 继续继承调用方剩余的 `DSH_CLIENT_*` 值。 + +`pnpm run build:official` 不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile。它的精确环境携带版本和 commit,设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用,并省略本地 dirty 元数据。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。`pnpm run dev:web` 在 watcher 启动前验证该记录,并在每个 stage 的整个生命周期内复用其中的公开环境;增量重建期间不会重新读取仓库状态。 ## Alternatives considered @@ -30,8 +32,10 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define **统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。 +**每次 watcher 重建都重新读取 Git 状态。** Vite 和 tsdown 在长驻 watcher 启动时固定 define 替换。仓库状态变化时重启构建流水线,会使一次增量源码修改重建无关产物;保留上次完整构建记录可以让每个 watcher stage 共用同一份元数据快照。 + ## Consequences -Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。 +Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的包版本和短源码 revision;dirty 的默认构建还会标明其源码存在未提交变化。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。watch build 会保留启动时的快照,直到另一次完整构建替换该记录。 任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 96a0a4549b..3a688bbe5c 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -29,22 +29,52 @@ if (!isBuildRecordReader(readClientBuildRecord)) { } const record: unknown = readClientBuildRecord(resolve(import.meta.dirname, '../../..')) if (typeof record !== 'object' || record === null) throw new TypeError('client build record must be an object') -const clientBuildEnvironment: unknown = Reflect.get(record, 'environment') -if (typeof clientBuildEnvironment !== 'object' || clientBuildEnvironment === null) { - throw new TypeError('client build record environment must be an object') -} +const clientBuildEnvironment = requireObject( + Reflect.get(record, 'environment'), + 'client build record environment must be an object', +) function isBuildRecordReader(value: unknown): value is (root: string) => unknown { return typeof value === 'function' } +function requireObject(value: unknown, message: string): Record { + if (!isUnknownRecord(value)) throw new TypeError(message) + return value +} + +function isUnknownRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Read one optional string from the verified client build record. */ +function clientBuildValue(name: string): string | undefined { + const value = clientBuildEnvironment[name] + if (value !== undefined && typeof value !== 'string') { + throw new TypeError(`client build record environment ${name} must be a string`) + } + return value +} + it('boots the built plugin graph and renders a fixture session end to end', async () => { mountAssembledApp() // The sidebar renders from the boot graph: every inject layer activated. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull() - expect(screen.queryByText('DSH Local Build')).toBeNull() + if (clientBuildValue('DSH_CLIENT_BUILD_PROFILE') === 'official') { + expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull() + expect(screen.queryByText('DSH Local Build')).toBeNull() + } else { + expect(document.querySelector('svg[viewBox="0 0 23.16 17.04"]')).not.toBeNull() + const version = clientBuildValue('DSH_CLIENT_VERSION') + if (version === undefined) throw new Error('default client build record must carry DSH_CLIENT_VERSION') + const commit = clientBuildValue('DSH_CLIENT_COMMIT_HASH') + const buildVersion = version + + (commit === undefined ? '' : `-${commit}`) + + (clientBuildValue('DSH_CLIENT_GIT_DIRTY') === 'true' ? '-dirty' : '') + screen.getByText('DSH') + screen.getByText(buildVersion) + } // The compact layout dropped group session counts; the fixture workspace // group row renders immediately with its sessions beneath it. const fixtureGroup = (await within(tree).findAllByText('fixture')) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index d8afbed4d5..890c386afe 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: 904245f93546122e4e3a54e020b302a3bcd39d1a -development.zh.md: 0faa0a1e06b6bc2cef23076c004fa4f4ba0a4360 +development.md: 16a2242d5d1b5d91718c6568b351aceb7439f38d +development.zh.md: 0db020bf66f1c16fbc6c987e60f5b61bf052cf62 diff --git a/docs/development.md b/docs/development.md index 904245f935..16a2242d5d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,7 +75,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. -`pnpm run build` embeds the caller's exact `DSH_CLIENT_*` environment and uses no public client values when none are set. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build. Each successful complete build writes a gitignored record that binds those values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing, development watchers, and built Web tests reject a missing record or artifacts changed by a later partial build. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. diff --git a/docs/development.zh.md b/docs/development.zh.md index 0faa0a1e06..0db020bf66 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -79,7 +79,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)。 -`pnpm run build` 会内联调用方精确的 `DSH_CLIENT_*` 环境;未设置时不使用任何公开 client 值。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令。每次完整构建成功后都会写入一份被 gitignore 的记录,把这些值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包、开发 watcher 和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 1dc37495d9..d33c1ce612 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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-sidebar/README.md -README.md: b924c2e6d18217d9689b7c21137321856e14da2e -README.zh.md: 214a2727243d6d9151e31cfcfbc0bdb886c2fcdf +README.md: 8a7963f1cd5cd0cf9e1d951e68aa53a35f0c4c3c +README.zh.md: 4c9040589ab8a28897821a2e476786ccf8ace50c diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index b924c2e6d1..8a7963f1cd 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Sidebar shell plugin: the brand row, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a `DSH Local Build` label carrying the build's 7-character `DSH_CLIENT_COMMIT_HASH` badge. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. +The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a local-build fallback: a complete build renders `DSH` plus a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata retains the defensive `DSH Local Build` label. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 214a272724..4c9040589a 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -4,7 +4,7 @@ 侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。 -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记,以及带有构建期 7 位 `DSH_CLIENT_COMMIT_HASH` 徽标的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`,并把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 及 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 44f3ddeaaa..fc2ec94aaa 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -210,7 +210,7 @@ color: var(--dsw-alias-label-primary); } -.buildRevision { +.buildVersion { display: inline-flex; align-items: center; height: 16px; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index d02518cb2d..3fb2a68fd9 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -34,6 +34,16 @@ const COLLAPSE_SETTLE_MS = 150 */ const SCROLLBAR_LINGER_MS = 2000 +/** Format complete-build metadata for the local brand badge. */ +function localBuildVersion(): string | undefined { + const version = process.env.DSH_CLIENT_VERSION + if (version === undefined) return undefined + const commit = process.env.DSH_CLIENT_COMMIT_HASH + return version + + (commit === undefined ? '' : `-${commit}`) + + (process.env.DSH_CLIENT_GIT_DIRTY === 'true' ? '-dirty' : '') +} + /** * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). @@ -111,6 +121,8 @@ export function SidebarRoot({ } }, [pointerInside]) + const buildVersion = localBuildVersion() + return (
- {t('brand.localBuild')} - {process.env.DSH_CLIENT_COMMIT_HASH - ? {process.env.DSH_CLIENT_COMMIT_HASH} - : null} + + {buildVersion === undefined ? 'DSH Local Build' : 'DSH'} + + {buildVersion === undefined + ? null + : {buildVersion}} ), })} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index ee59061984..7780754125 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -140,12 +140,12 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul - DSH Local Build + DSH - abc1234 + 1.2.3-rc.4-abc1234-dirty
@@ -266,12 +266,12 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo - DSH 本地构建 + DSH - abc1234 + 1.2.3-rc.4-abc1234-dirty
diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 3ba66aa4ff..948bd28d2b 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -100,6 +100,8 @@ describe('SidebarRoot shell', () => { it('renders generic brand fallbacks when no package fills the slots', () => { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456') + vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true') + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4') const { container } = render( { options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) - expect(screen.getByText('DSH Local Build')).toBeTruthy() - expect(screen.getByText('0123456')).toBeTruthy() + expect(screen.getByText('DSH')).toBeTruthy() + expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy() expect(container.querySelector('svg')).not.toBeNull() }) + it.each([ + [{ DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3'], + [{ DSH_CLIENT_COMMIT_HASH: 'abcdef0', DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3-abcdef0'], + ])('omits unavailable build-version suffixes from %j', (environment, expected) => { + for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value) + render( + options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} + />) + + expect(screen.getByText('DSH')).toBeTruthy() + expect(screen.getByText(expected)).toBeTruthy() + }) + + it('retains the local-build fallback without complete build metadata', () => { + render( + options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} + />) + + expect(screen.getByText('DSH Local Build')).toBeTruthy() + }) + it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => { const b = mountShell() expect(b.regionOwner().wide).toBe(true) diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx index a1c9b22621..a85d31cc02 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx @@ -20,7 +20,11 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -beforeEach(() => { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', 'abc1234') }) +beforeEach(() => { + vi.stubEnv('DSH_CLIENT_COMMIT_HASH', 'abc1234') + vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true') + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4') +}) afterEach(() => { cleanup() diff --git a/scripts/build.ts b/scripts/build.ts index b8ffa8dbc0..6d6bcb259f 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -6,8 +6,9 @@ import { resolve } from 'node:path' import { parseArgs } from 'node:util' import { CLIENT_BUILD_RECORD_PATH, + CLIENT_BUILD_PROFILE_SELECTOR, clientBuildProcessEnvironment, - repositoryCommitHash, + repositoryClientBuildEnvironment, resolveClientBuildEnvironment, writeClientBuildRecord, } from './client-build-environment.ts' @@ -34,12 +35,10 @@ function main(): void { allowPositionals: false, }) const root = resolve(import.meta.dirname, '..') - const parentEnvironment = { - ...process.env, - DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, process.env), - } - const clientEnvironment = resolveClientBuildEnvironment(parentEnvironment, values.profile) - const buildEnvironment = clientBuildProcessEnvironment(parentEnvironment, clientEnvironment) + const repositoryEnvironment = repositoryClientBuildEnvironment(root, process.env) + const profile = values.profile ?? process.env[CLIENT_BUILD_PROFILE_SELECTOR] + const clientEnvironment = resolveClientBuildEnvironment(repositoryEnvironment, profile) + const buildEnvironment = clientBuildProcessEnvironment(process.env, clientEnvironment) rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true }) runScript('build:lib', buildEnvironment) diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts index e2eb0ebe72..74e8086f43 100644 --- a/scripts/client-build-environment.client.spec.ts +++ b/scripts/client-build-environment.client.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' @@ -7,8 +8,12 @@ import { assertClientBuildEnvironment, clientBuildEnvironmentDefines, clientBuildProcessEnvironment, + officialClientBuildEnvironment, readClientBuildRecord, + repositoryClientBuildEnvironment, repositoryCommitHash, + repositoryGitDirty, + repositoryVersion, resolveClientBuildEnvironment, writeClientBuildRecord, } from './client-build-environment.ts' @@ -51,12 +56,34 @@ function buildFixture(environment: Record): string { return fixtureRoot } +function git(root: string, args: readonly string[]): string { + return execFileSync('git', [...args], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +function repositoryFixture(version = '1.2.3-rc.4'): string { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-repository-')) + roots.push(fixtureRoot) + write(join(fixtureRoot, 'package.json'), `${JSON.stringify({ version })}\n`) + write(join(fixtureRoot, 'tracked.txt'), 'committed\n') + git(fixtureRoot, ['init']) + git(fixtureRoot, ['config', 'user.name', 'DSH test']) + git(fixtureRoot, ['config', 'user.email', 'dsh-test@example.invalid']) + git(fixtureRoot, ['add', 'package.json', 'tracked.txt']) + git(fixtureRoot, ['commit', '-m', 'fixture']) + return fixtureRoot +} + describe('client build environment', () => { it('requires an exact public environment for a named artifact profile', () => { const expected = { DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', } as const expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow() @@ -73,7 +100,9 @@ describe('client build environment', () => { DSH_BUILD_CLIENT_PROFILE: 'official', DSH_CLIENT_BUILD_PROFILE: 'local', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_GIT_DIRTY: 'true', DSH_CLIENT_TITLE: 'Local title', + DSH_CLIENT_VERSION: '1.2.3', DSH_CLIENT_EXTRA: 'local-extra', } @@ -84,24 +113,108 @@ describe('client build environment', () => { DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', }) expect(() => { resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' }) }).toThrow(/DSH_CLIENT_COMMIT_HASH/) + expect(() => { + resolveClientBuildEnvironment({ + DSH_BUILD_CLIENT_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + }) + }).toThrow(/DSH_CLIENT_VERSION/) expect(() => { resolveClientBuildEnvironment({}, 'unknown') }).toThrow(/unknown client build profile/) expect(clientBuildProcessEnvironment(parent, { DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', })).toEqual({ PATH: '/bin', DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', }) expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7)) }) + it('owns repository version, commit, and dirty metadata for complete builds', () => { + const fixtureRoot = repositoryFixture() + const commit = git(fixtureRoot, ['rev-parse', '--short=7', 'HEAD']) + + expect(repositoryVersion(fixtureRoot)).toBe('1.2.3-rc.4') + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + expect(repositoryClientBuildEnvironment(fixtureRoot, { + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH, + DSH_CLIENT_EXTRA: 'preserved', + DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_VERSION: 'spoofed', + })).toEqual({ + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_EXTRA: 'preserved', + DSH_CLIENT_VERSION: '1.2.3-rc.4', + }) + expect(officialClientBuildEnvironment(fixtureRoot)).toEqual({ + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: commit, + DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3-rc.4', + }) + + write(join(fixtureRoot, '.gitignore'), 'ignored.txt\n') + git(fixtureRoot, ['add', '.gitignore']) + git(fixtureRoot, ['commit', '-m', 'ignore fixture']) + write(join(fixtureRoot, 'ignored.txt'), 'ignored\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + rmSync(join(fixtureRoot, 'ignored.txt')) + + write(join(fixtureRoot, 'tracked.txt'), 'unstaged\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(true) + write(join(fixtureRoot, 'tracked.txt'), 'committed\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + + write(join(fixtureRoot, 'tracked.txt'), 'staged\n') + git(fixtureRoot, ['add', 'tracked.txt']) + expect(repositoryGitDirty(fixtureRoot)).toBe(true) + git(fixtureRoot, ['commit', '-m', 'staged fixture']) + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + + write(join(fixtureRoot, 'untracked.txt'), 'untracked\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(true) + expect(repositoryClientBuildEnvironment(fixtureRoot, { + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH, + })).toEqual({ + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_VERSION: '1.2.3-rc.4', + }) + + rmSync(join(fixtureRoot, 'untracked.txt')) + const submoduleSource = repositoryFixture('9.8.7') + git(fixtureRoot, ['-c', 'protocol.file.allow=always', 'submodule', 'add', submoduleSource, 'submodule']) + git(fixtureRoot, ['commit', '-am', 'submodule fixture']) + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + write(join(fixtureRoot, 'submodule/tracked.txt'), 'modified submodule\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(true) + }) + + it('omits dirty metadata when repository metadata is unavailable', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-no-git-')) + roots.push(fixtureRoot) + write(join(fixtureRoot, 'package.json'), '{"version":"2.0.0"}\n') + + expect(repositoryGitDirty(fixtureRoot)).toBeUndefined() + expect(repositoryClientBuildEnvironment(fixtureRoot, { + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH, + DSH_CLIENT_GIT_DIRTY: 'true', + })).toEqual({ + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_VERSION: '2.0.0', + }) + }) + it('defines only public client values over a non-enumerable fallback', () => { expect(clientBuildEnvironmentDefines({ PATH: '/bin', @@ -151,6 +264,7 @@ describe('client build environment', () => { DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', } const official = buildFixture(officialEnvironment) const defaultBuild = buildFixture({}) diff --git a/scripts/client-build-environment.ts b/scripts/client-build-environment.ts index 2331db5f42..13ea20cdc1 100644 --- a/scripts/client-build-environment.ts +++ b/scripts/client-build-environment.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { existsSync, globSync, @@ -25,6 +25,9 @@ const OFFICIAL_CLIENT_BUILD_ENVIRONMENT = { /** Public variable carrying the source commit embedded in client artifacts. */ const CLIENT_COMMIT_HASH_VARIABLE = 'DSH_CLIENT_COMMIT_HASH' +/** Public variable carrying the repository package version embedded in client artifacts. */ +const CLIENT_VERSION_VARIABLE = 'DSH_CLIENT_VERSION' + /** Repository-relative path of the complete client build record. */ export const CLIENT_BUILD_RECORD_PATH = '.dsh-build/client-build-environment.json' @@ -57,6 +60,76 @@ export function repositoryCommitHash(root: string, environment: NodeJS.ProcessEn return value.slice(0, 7).toLowerCase() } +/** + * Resolve the repository package version used by browser build metadata. + * @param root - repository root containing the authoritative package.json. + * @returns the repository's semver-compatible package version. + */ +export function repositoryVersion(root: string): string { + const path = resolve(root, 'package.json') + let manifest: unknown + try { + manifest = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`cannot read repository version from ${path}: ${detail}`) + } + if (!isObject(manifest) || typeof manifest.version !== 'string' + || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)) { + throw new Error(`repository package.json has an invalid version ${JSON.stringify(isObject(manifest) ? manifest.version : undefined)}`) + } + return manifest.version +} + +/** + * Read whether Git reports any staged, unstaged, untracked, or submodule change. + * @param root - repository root whose worktree is inspected. + * @returns true or false inside a Git worktree; undefined without Git metadata. + */ +export function repositoryGitDirty(root: string): boolean | undefined { + const probe = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (probe.error !== undefined || probe.status !== 0 || probe.stdout.trim() !== 'true') return undefined + + const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=normal'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (status.error !== undefined) throw status.error + if (status.status !== 0) { + throw new Error(`git status failed in ${root}: ${status.stderr.trim() || String(status.status)}`) + } + return status.stdout !== '' +} + +/** + * Resolve the public environment for a complete default build from one checkout. + * Repository-owned metadata replaces inherited values; other public values pass through. + * @param root - repository root supplying version and Git metadata. + * @param environment - caller environment supplying optional commit and public extensions. + * @returns complete public client environment for the default build. + */ +export function repositoryClientBuildEnvironment( + root: string, + environment: NodeJS.ProcessEnv = process.env, +): ClientBuildEnvironment { + const inherited = { ...clientBuildEnvironment(environment) } + delete inherited.DSH_CLIENT_COMMIT_HASH + delete inherited.DSH_CLIENT_GIT_DIRTY + delete inherited.DSH_CLIENT_VERSION + const dirty = repositoryGitDirty(root) + return { + ...inherited, + DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment), + ...(dirty === true ? { DSH_CLIENT_GIT_DIRTY: 'true' } : {}), + DSH_CLIENT_VERSION: repositoryVersion(root), + } +} + /** * Resolve the exact public values required by an official build at one commit. * @param root - repository root whose HEAD must match the built source. @@ -69,6 +142,7 @@ export function officialClientBuildEnvironment( ): Readonly> { return { DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment), + DSH_CLIENT_VERSION: repositoryVersion(root), ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT, } } @@ -115,10 +189,18 @@ export function resolveClientBuildEnvironment( if (profile === undefined) return clientBuildEnvironment(environment) if (profile === 'official') { const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE] + const version = environment[CLIENT_VERSION_VARIABLE] if (commitHash === undefined) { throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the official client build profile`) } - return { DSH_CLIENT_COMMIT_HASH: commitHash, ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT } + if (version === undefined) { + throw new Error(`${CLIENT_VERSION_VARIABLE} is required for the official client build profile`) + } + return { + DSH_CLIENT_COMMIT_HASH: commitHash, + DSH_CLIENT_VERSION: version, + ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT, + } } throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`) } diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index f185ee6754..4cfb15680d 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -3,7 +3,42 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import type { TsdownBundle } from 'tsdown' -import { discoverLibraryDirs, discoverPluginDirs, watchClientPlugins } from './dev-web.ts' +import { writeClientBuildRecord } from './client-build-environment.ts' +import { + devWebBuildEnvironment, + discoverLibraryDirs, + discoverPluginDirs, + watchClientPlugins, +} from './dev-web.ts' + +it('reuses the verified complete-build environment for every watcher stage', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-')) + try { + await mkdir(join(root, 'apps/web/dist'), { recursive: true }) + await mkdir(join(root, 'packages/client/example/lib'), { recursive: true }) + await writeFile(join(root, 'apps/web/dist/index.html'), '
') + await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') + writeClientBuildRecord(root, { + DSH_CLIENT_COMMIT_HASH: 'abc1234', + DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_VERSION: '1.2.3', + }) + + expect(devWebBuildEnvironment(root, { + PATH: '/bin', + DSH_BUILD_CLIENT_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: 'stale', + DSH_CLIENT_EXTRA: 'stale', + })).toEqual({ + PATH: '/bin', + DSH_CLIENT_COMMIT_HASH: 'abc1234', + DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_VERSION: '1.2.3', + }) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) it('discovers dsh.client packages with sibling roles', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-')) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 05f929f1b8..21775c9580 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -34,6 +34,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { execa } from 'execa' import { build } from 'tsdown' import type { TsdownBundle } from 'tsdown' +import { + CLIENT_BUILD_PROFILE_SELECTOR, + clientBuildProcessEnvironment, + readClientBuildRecord, +} from './client-build-environment.ts' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -49,6 +54,19 @@ const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend' */ const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/' +/** + * Reuse the last complete build's public metadata for every long-lived watcher. + * @param root - repository root containing the verified client build record. + * @param environment - watcher launch environment whose public values are replaced. + * @returns process environment shared by tsdown and spawned watcher stages. + */ +export function devWebBuildEnvironment( + root: string, + environment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return clientBuildProcessEnvironment(environment, readClientBuildRecord(root).environment) +} + /** * Discover the watch workspace by declaration: every packages// * whose package.json carries `dsh.client` with platform "web" is a client @@ -175,6 +193,16 @@ interface StageHandle { const invokedPath = process.argv[1] const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href if (isMain) { + const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env) + for (const name of Object.keys(process.env)) { + if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) { + Reflect.deleteProperty(process.env, name) + } + } + for (const [name, value] of Object.entries(buildEnvironment)) { + if (name.startsWith('DSH_CLIENT_') && value !== undefined) process.env[name] = value + } + const pluginDirs = discoverPluginDirs() const libraryDirs = discoverLibraryDirs() if (pluginDirs.length === 0) { From b636b01092e6756206721f663911628b07c4060d Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 16:52:32 +0800 Subject: [PATCH 039/138] test: update client build fixtures --- scripts/release/families.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index d22db243d8..c3ae3c8c2f 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -29,6 +29,7 @@ function write(path: string, content: string): void { function buildFixture(environment: Record): string { const root = mkdtempSync(join(tmpdir(), 'dsh-release-build-')) roots.push(root) + write(join(root, 'package.json'), `${JSON.stringify({ version: environment.DSH_CLIENT_VERSION ?? '0.0.1' })}\n`) write(join(root, 'apps/web/dist/index.html'), '
') write(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') writeClientBuildRecord(root, environment) @@ -106,11 +107,13 @@ describe('release families', () => { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', officialEnvironment.DSH_CLIENT_COMMIT_HASH) const official = buildFixture(officialEnvironment) const defaultBuild = buildFixture({}) + const missing = join(defaultBuild, 'missing') + write(join(missing, 'package.json'), `${JSON.stringify({ version: officialEnvironment.DSH_CLIENT_VERSION })}\n`) expect(() => { dsh.verifyBuildArtifacts(official) }).not.toThrow() expect(() => { dsh.verifyBuildArtifacts(defaultBuild) }).toThrow(/DSH_CLIENT_TITLE/) - expect(() => { dsh.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/) - expect(() => { vendor.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).not.toThrow() + expect(() => { dsh.verifyBuildArtifacts(missing) }).toThrow(/record.*missing/) + expect(() => { vendor.verifyBuildArtifacts(missing) }).not.toThrow() write(join(official, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n') expect(() => { dsh.verifyBuildArtifacts(official) }).toThrow(/artifacts differ/) From 749c4ef93edb2f9ac5fdeb4b24687c5d7b36f42c Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 17:22:40 +0800 Subject: [PATCH 040/138] fix: sample dev web metadata at startup --- ...026-08-18-client-build-environment.i18n.yaml | 4 ++-- .../2026-08-18-client-build-environment.md | 8 +++++--- .../2026-08-18-client-build-environment.zh.md | 8 +++++--- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/dev-web.spec.ts | 17 ++++++++++------- scripts/dev-web.ts | 10 +++++----- 8 files changed, 31 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml index e778ec3085..7a9fa44aef 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.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-18-client-build-environment.md -2026-08-18-client-build-environment.md: 590f53521476b5195acbad73c452fac3b2f8f914 -2026-08-18-client-build-environment.zh.md: 8803463663d3d3bd08f5abdbb6f54ed3795fe6a8 +2026-08-18-client-build-environment.md: c23a73e9520392e41949f95d04029dbf34213dd0 +2026-08-18-client-build-environment.zh.md: 5dbb5deeee75eb0f1b2cadf7e38ad8bfc5661f5c diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md index 590f535214..c23a73e952 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -20,7 +20,7 @@ The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, p The root build wrapper supplies one exact public environment to both bundlers. Every complete build carries the root package version as `DSH_CLIENT_VERSION` and the seven-character source Git HEAD prefix as `DSH_CLIENT_COMMIT_HASH`; an explicit commit supports build environments without repository metadata. A default local build also samples Git status before building and sets `DSH_CLIENT_GIT_DIRTY=true` for any staged, unstaged, untracked, or submodule change. Clean checkouts and sources without Git metadata omit the dirty field. These repository-owned fields replace inherited values, while `pnpm run build` otherwise inherits the caller's remaining `DSH_CLIENT_*` values. -`pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax. Its exact environment carries the version and commit, sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations, and omits local dirty metadata. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. `pnpm run dev:web` verifies this record before its watcher stages start and reuses its public environment for the lifetime of every stage; it does not resample repository state during incremental rebuilds. +`pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax. Its exact environment carries the version and commit, sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations, and omits local dirty metadata. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. `pnpm run dev:web` instead samples the default local environment once at startup and passes that environment to every watcher stage for the session. It does not validate the complete-build record because the watcher stages rewrite every recorded artifact. ## Alternatives considered @@ -32,10 +32,12 @@ The root build wrapper supplies one exact public environment to both bundlers. E **Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path. -**Resample Git state during every watcher rebuild.** Vite and tsdown fix define substitutions when their long-lived watchers start. Restarting the build pipeline on repository status changes would make an incremental source edit rebuild unrelated artifacts; retaining the last complete build record gives every watcher stage one consistent metadata snapshot. +**Reuse the last complete-build record for watchers.** Watcher stages rewrite every recorded client artifact, so the artifact digest becomes stale during normal development. An official-build record would also make edited local source retain the official profile and title. Startup sampling gives every stage one local metadata snapshot without coupling watcher restarts to the recorded artifact digest. + +**Resample Git state during every watcher rebuild.** Vite and tsdown fix define substitutions when their long-lived watchers start. Restarting the build pipeline on repository status changes would make an incremental source edit rebuild unrelated artifacts; sampling once at startup keeps the stages consistent without rebuilding for later status changes. ## Consequences -The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its package version and short source revision as public display metadata; a dirty default build additionally identifies its uncommitted source state. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. Watch builds retain their startup snapshot until another complete build replaces the record. +The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its package version and short source revision as public display metadata; a dirty default build additionally identifies its uncommitted source state. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. Watch builds retain their startup snapshot until `pnpm run dev:web` restarts. Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism. diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md index 8803463663..5dbb5deeee 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -20,7 +20,7 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define 根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都以 `DSH_CLIENT_VERSION` 携带根包版本,并以 `DSH_CLIENT_COMMIT_HASH` 携带源码 Git HEAD 的七位前缀;没有仓库元数据的构建环境可显式提供 commit。默认本地构建还会在构建前读取 Git 状态;存在任何暂存、未暂存、未跟踪或子模块变化时设置 `DSH_CLIENT_GIT_DIRTY=true`。没有变化的 worktree 和没有 Git 元数据的源码不携带 dirty 字段。这些由仓库持有的字段会替换继承值,除此之外,`pnpm run build` 继续继承调用方剩余的 `DSH_CLIENT_*` 值。 -`pnpm run build:official` 不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile。它的精确环境携带版本和 commit,设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用,并省略本地 dirty 元数据。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。`pnpm run dev:web` 在 watcher 启动前验证该记录,并在每个 stage 的整个生命周期内复用其中的公开环境;增量重建期间不会重新读取仓库状态。 +`pnpm run build:official` 不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile。它的精确环境携带版本和 commit,设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用,并省略本地 dirty 元数据。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。`pnpm run dev:web` 则会在启动时读取一次默认本地环境,并在本次会话中把该环境传给所有 watcher stage。它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的全部产物。 ## Alternatives considered @@ -32,10 +32,12 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define **统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。 -**每次 watcher 重建都重新读取 Git 状态。** Vite 和 tsdown 在长驻 watcher 启动时固定 define 替换。仓库状态变化时重启构建流水线,会使一次增量源码修改重建无关产物;保留上次完整构建记录可以让每个 watcher stage 共用同一份元数据快照。 +**让 watcher 复用上次完整构建记录。** watcher stage 会重写记录覆盖的全部 client 产物,因此正常开发期间产物摘要就会变为陈旧。官方构建记录还会让经过编辑的本地源码继续携带官方 profile 和标题。启动时读取一次可以让所有 stage 共用同一份本地元数据快照,同时避免 watcher 重启依赖记录的产物摘要。 + +**每次 watcher 重建都重新读取 Git 状态。** Vite 和 tsdown 在长驻 watcher 启动时固定 define 替换。仓库状态变化时重启构建流水线,会使一次增量源码修改重建无关产物;启动时只读取一次可以保持各 stage 一致,同时避免因后续状态变化而重新构建。 ## Consequences -Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的包版本和短源码 revision;dirty 的默认构建还会标明其源码存在未提交变化。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。watch build 会保留启动时的快照,直到另一次完整构建替换该记录。 +Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的包版本和短源码 revision;dirty 的默认构建还会标明其源码存在未提交变化。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。watch build 会保留启动时的快照,直到 `pnpm run dev:web` 重启。 任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 890c386afe..07b306bede 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: 16a2242d5d1b5d91718c6568b351aceb7439f38d -development.zh.md: 0db020bf66f1c16fbc6c987e60f5b61bf052cf62 +development.md: d37be2802004a044ea3516506bbbb51305c622de +development.zh.md: 3a661c3356a77f6c8c85316e74cf14a97fd90489 diff --git a/docs/development.md b/docs/development.md index 16a2242d5d..d37be28020 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,7 +75,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing, development watchers, and built Web tests reject a missing record or artifacts changed by a later partial build. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. diff --git a/docs/development.zh.md b/docs/development.zh.md index 0db020bf66..3a661c3356 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -79,7 +79,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包、开发 watcher 和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 4cfb15680d..76dbf82860 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -11,28 +11,31 @@ import { watchClientPlugins, } from './dev-web.ts' -it('reuses the verified complete-build environment for every watcher stage', async () => { +it('samples one local environment at startup without validating watcher outputs', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-')) try { await mkdir(join(root, 'apps/web/dist'), { recursive: true }) await mkdir(join(root, 'packages/client/example/lib'), { recursive: true }) + await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.2.3' })) await writeFile(join(root, 'apps/web/dist/index.html'), '
') await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') writeClientBuildRecord(root, { - DSH_CLIENT_COMMIT_HASH: 'abc1234', - DSH_CLIENT_GIT_DIRTY: 'true', - DSH_CLIENT_VERSION: '1.2.3', + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: 'fffffff', + DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.2', }) + await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n') expect(devWebBuildEnvironment(root, { PATH: '/bin', DSH_BUILD_CLIENT_PROFILE: 'official', - DSH_CLIENT_COMMIT_HASH: 'stale', - DSH_CLIENT_EXTRA: 'stale', + DSH_CLIENT_COMMIT_HASH: 'abc1234', + DSH_CLIENT_EXTRA: 'launch-value', })).toEqual({ PATH: '/bin', DSH_CLIENT_COMMIT_HASH: 'abc1234', - DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_EXTRA: 'launch-value', DSH_CLIENT_VERSION: '1.2.3', }) } finally { diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 21775c9580..35500eca13 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -37,7 +37,7 @@ import type { TsdownBundle } from 'tsdown' import { CLIENT_BUILD_PROFILE_SELECTOR, clientBuildProcessEnvironment, - readClientBuildRecord, + repositoryClientBuildEnvironment, } from './client-build-environment.ts' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -55,16 +55,16 @@ const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend' const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/' /** - * Reuse the last complete build's public metadata for every long-lived watcher. - * @param root - repository root containing the verified client build record. - * @param environment - watcher launch environment whose public values are replaced. + * Sample one local public environment for every long-lived watcher stage. + * @param root - repository root supplying version and Git metadata. + * @param environment - watcher launch environment supplying public extensions. * @returns process environment shared by tsdown and spawned watcher stages. */ export function devWebBuildEnvironment( root: string, environment: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - return clientBuildProcessEnvironment(environment, readClientBuildRecord(root).environment) + return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment)) } /** From 17bde3f5bea240c152fcddd51340a48adb1b9f22 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 18:00:10 +0800 Subject: [PATCH 041/138] feat: label local build banner --- apps/web/tests/built-boot.snapshot.ts | 1 + packages/client/ui-sidebar/README.i18n.yaml | 4 +-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 26 +++++++++++++++++++ .../ui-sidebar/src/client/SidebarRoot.tsx | 21 ++++++++------- .../sidebar-snapshot.client.spec.tsx.snap | 26 ++++++++++++++++--- .../tests/sidebar-root.client.spec.tsx | 2 ++ 8 files changed, 66 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 3a688bbe5c..6999543b00 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -73,6 +73,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn + (commit === undefined ? '' : `-${commit}`) + (clientBuildValue('DSH_CLIENT_GIT_DIRTY') === 'true' ? '-dirty' : '') screen.getByText('DSH') + screen.getByText('Local Build') screen.getByText(buildVersion) } // The compact layout dropped group session counts; the fixture workspace diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index d33c1ce612..2a3119689c 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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-sidebar/README.md -README.md: 8a7963f1cd5cd0cf9e1d951e68aa53a35f0c4c3c -README.zh.md: 4c9040589ab8a28897821a2e476786ccf8ace50c +README.md: 406f2930ac1deb9bf8e12caedbcfb11f7fc35fbc +README.zh.md: 147c30b5de5db07bbffa665157c3707430c0a099 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 8a7963f1cd..406f2930ac 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Sidebar shell plugin: the brand row, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a local-build fallback: a complete build renders `DSH` plus a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata retains the defensive `DSH Local Build` label. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. +The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a local-build fallback: a complete build renders `DSH` with a small `Local Build` caption plus a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata retains the defensive `DSH Local Build` label. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 4c9040589a..147c30b5de 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -4,7 +4,7 @@ 侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。 -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`,并把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 及 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`、小号 `Local Build` 标注,以及把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成的 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index fc2ec94aaa..d280ab647f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -159,6 +159,30 @@ white-space: nowrap; } +.localBuildBrand { + flex: none; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + height: 24px; + white-space: nowrap; +} + +.localBuildName { + font-size: 16px; + line-height: 15px; + letter-spacing: 0; +} + +.localBuildLabel { + color: var(--dsw-alias-label-secondary); + font-size: 7px; + font-weight: 500; + line-height: 8px; + letter-spacing: 0.02em; +} + .iconButton { flex: none; display: inline-flex; @@ -211,6 +235,7 @@ } .buildVersion { + flex: none; display: inline-flex; align-items: center; height: 16px; @@ -222,6 +247,7 @@ font-size: 8px; font-weight: 500; line-height: 16px; + white-space: nowrap; } /* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 3fb2a68fd9..bdc5140b24 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -153,16 +153,17 @@ export function SidebarRoot({ {renderSlot('sidebar.brand.name', {}, { - fallback: ( - <> - - {buildVersion === undefined ? 'DSH Local Build' : 'DSH'} - - {buildVersion === undefined - ? null - : {buildVersion}} - - ), + fallback: buildVersion === undefined + ? DSH Local Build + : ( + <> + + DSH + Local Build + + {buildVersion} + + ), })} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index 7780754125..929ce9d72a 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -138,9 +138,18 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul style="display: contents;" > - DSH + + DSH + + + Local Build + renders the expanded column in the default lo style="display: contents;" > - DSH + + DSH + + + Local Build + { />) expect(screen.getByText('DSH')).toBeTruthy() + expect(screen.getByText('Local Build')).toBeTruthy() expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy() expect(container.querySelector('svg')).not.toBeNull() }) @@ -129,6 +130,7 @@ describe('SidebarRoot shell', () => { />) expect(screen.getByText('DSH')).toBeTruthy() + expect(screen.getByText('Local Build')).toBeTruthy() expect(screen.getByText(expected)).toBeTruthy() }) From 720c5c247cc9ff3755a6e627e5efb39bd2d6f243 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 24 Aug 2026 11:48:38 +0800 Subject: [PATCH 042/138] fix(client): localize local build banner --- packages/client/locale/src/locales/en.ts | 2 ++ packages/client/locale/src/locales/zh.ts | 2 ++ packages/client/ui-sidebar/README.i18n.yaml | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- packages/client/ui-sidebar/src/client/SidebarRoot.tsx | 6 +++--- .../__snapshots__/sidebar-snapshot.client.spec.tsx.snap | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index bb4347c085..cd590c78b1 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -30,7 +30,9 @@ export const en = { 'collapse': 'Collapse', 'expand': 'Expand', 'back': 'Back', + 'brand.name': 'DSH', 'brand.localBuild': 'DSH Local Build', + 'brand.localBuildLabel': 'Local Build', 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index d5b9a45cfd..1d25d446c1 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -28,7 +28,9 @@ export const zh = { 'collapse': '收起', 'expand': '展开', 'back': '返回', + 'brand.name': 'DSH', 'brand.localBuild': 'DSH 本地构建', + 'brand.localBuildLabel': '本地构建', 'unknown': '未知', 'none': '无', 'truncated': '已截断', diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 2a3119689c..e2be13acb2 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md README.md: 406f2930ac1deb9bf8e12caedbcfb11f7fc35fbc -README.zh.md: 147c30b5de5db07bbffa665157c3707430c0a099 +README.zh.md: fd809b20298d069b37076c8dde4fe98f8fb761c8 diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 147c30b5de..fd809b2029 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -4,7 +4,7 @@ 侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。 -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`、小号 `Local Build` 标注,以及把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成的 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`、小号 `本地构建` 标注,以及把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成的 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH 本地构建` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index bdc5140b24..fc6987d5ba 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -154,12 +154,12 @@ export function SidebarRoot({ {renderSlot('sidebar.brand.name', {}, { fallback: buildVersion === undefined - ? DSH Local Build + ? {t('brand.localBuild')} : ( <> - DSH - Local Build + {t('brand.name')} + {t('brand.localBuildLabel')} {buildVersion} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index 929ce9d72a..dd0d957272 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -283,7 +283,7 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo - Local Build + 本地构建 Date: Mon, 24 Aug 2026 15:03:49 +0800 Subject: [PATCH 043/138] style(client): stack local build metadata --- apps/web/tests/built-boot.snapshot.ts | 3 +-- packages/client/locale/src/locales/en.ts | 2 -- packages/client/locale/src/locales/zh.ts | 2 -- packages/client/ui-sidebar/README.i18n.yaml | 4 +-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 25 +++++++----------- .../ui-sidebar/src/client/SidebarRoot.tsx | 9 +++---- .../sidebar-snapshot.client.spec.tsx.snap | 26 ++++++------------- .../tests/sidebar-root.client.spec.tsx | 6 ++--- 10 files changed, 27 insertions(+), 54 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 6999543b00..cbd5f294a4 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -72,8 +72,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const buildVersion = version + (commit === undefined ? '' : `-${commit}`) + (clientBuildValue('DSH_CLIENT_GIT_DIRTY') === 'true' ? '-dirty' : '') - screen.getByText('DSH') - screen.getByText('Local Build') + screen.getByText('DSH Local Build') screen.getByText(buildVersion) } // The compact layout dropped group session counts; the fixture workspace diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index cd590c78b1..bb4347c085 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -30,9 +30,7 @@ export const en = { 'collapse': 'Collapse', 'expand': 'Expand', 'back': 'Back', - 'brand.name': 'DSH', 'brand.localBuild': 'DSH Local Build', - 'brand.localBuildLabel': 'Local Build', 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 1d25d446c1..d5b9a45cfd 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -28,9 +28,7 @@ export const zh = { 'collapse': '收起', 'expand': '展开', 'back': '返回', - 'brand.name': 'DSH', 'brand.localBuild': 'DSH 本地构建', - 'brand.localBuildLabel': '本地构建', 'unknown': '未知', 'none': '无', 'truncated': '已截断', diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index e2be13acb2..fe0b552cee 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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-sidebar/README.md -README.md: 406f2930ac1deb9bf8e12caedbcfb11f7fc35fbc -README.zh.md: fd809b20298d069b37076c8dde4fe98f8fb761c8 +README.md: 075a132b9fc35ec4aee871690b436d3380e616fb +README.zh.md: 39ec5d58cd7270fbefdaa81eb0a6a022229d7963 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 406f2930ac..075a132b9f 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Sidebar shell plugin: the brand row, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a local-build fallback: a complete build renders `DSH` with a small `Local Build` caption plus a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata retains the defensive `DSH Local Build` label. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. +The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks below it a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index fd809b2029..39ec5d58cd 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -4,7 +4,7 @@ 侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。 -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`、小号 `本地构建` 标注,以及把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成的 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH 本地构建` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标由 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index d280ab647f..d472581e43 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -165,24 +165,17 @@ flex-direction: column; align-items: flex-start; justify-content: center; + gap: 1px; height: 24px; white-space: nowrap; } -.localBuildName { - font-size: 16px; - line-height: 15px; +.localBuildTitle { + font-size: 12px; + line-height: 13px; letter-spacing: 0; } -.localBuildLabel { - color: var(--dsw-alias-label-secondary); - font-size: 7px; - font-weight: 500; - line-height: 8px; - letter-spacing: 0.02em; -} - .iconButton { flex: none; display: inline-flex; @@ -238,15 +231,15 @@ flex: none; display: inline-flex; align-items: center; - height: 16px; - padding: 0 4px; - border-radius: 3px; + height: 10px; + padding: 0 3px; + border-radius: 2px; color: var(--dsw-alias-label-primary-inverted); background: var(--dsw-alias-label-primary); font-family: var(--ds-font-family-code); - font-size: 8px; + font-size: 6px; font-weight: 500; - line-height: 16px; + line-height: 10px; white-space: nowrap; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index fc6987d5ba..9dde97bf9e 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -156,13 +156,10 @@ export function SidebarRoot({ fallback: buildVersion === undefined ? {t('brand.localBuild')} : ( - <> - - {t('brand.name')} - {t('brand.localBuildLabel')} - + + {t('brand.localBuild')} {buildVersion} - + ), })} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index dd0d957272..ba3e4d9540 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -141,21 +141,16 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul class="localBuildBrand" > - DSH + DSH Local Build - Local Build + 1.2.3-rc.4-abc1234-dirty - - 1.2.3-rc.4-abc1234-dirty -
@@ -276,21 +271,16 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo class="localBuildBrand" > - DSH + DSH 本地构建 - 本地构建 + 1.2.3-rc.4-abc1234-dirty - - 1.2.3-rc.4-abc1234-dirty - diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 63812450a9..0f5edce0de 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -110,8 +110,7 @@ describe('SidebarRoot shell', () => { options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) - expect(screen.getByText('DSH')).toBeTruthy() - expect(screen.getByText('Local Build')).toBeTruthy() + expect(screen.getByText('DSH Local Build')).toBeTruthy() expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy() expect(container.querySelector('svg')).not.toBeNull() }) @@ -129,8 +128,7 @@ describe('SidebarRoot shell', () => { options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) - expect(screen.getByText('DSH')).toBeTruthy() - expect(screen.getByText('Local Build')).toBeTruthy() + expect(screen.getByText('DSH Local Build')).toBeTruthy() expect(screen.getByText(expected)).toBeTruthy() }) From 15ddb2edc42d413d708a74214e193c397e14de8f Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 24 Aug 2026 15:28:14 +0800 Subject: [PATCH 044/138] test(web): wait for stable preview onboarding --- apps/web/tests/preview-boot.e2e.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 8ef63e1067..dd8daa9dad 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -302,12 +302,10 @@ async function bootPreview(origin: string, browser: Browser): Promise { // report the older one. expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) expect(bootLine).toContain('data overlays=1') - // The hero's workspace picker is the client tree's first interactive - // surface, so it appears only once the startup chain completed over the - // tunnel. - await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + // The versioned notice is the seeded preview's first stable interactive + // surface after the startup chain completes over the tunnel. const continueButton = page.getByRole('button', { name: 'Continue' }) - await continueButton.waitFor({ timeout: 30_000 }) + await continueButton.waitFor({ timeout: HERO_TIMEOUT_MS }) await continueButton.click() const configureLater = page.getByRole('button', { name: 'Configure later' }) await configureLater.waitFor({ timeout: 30_000 }) From 2f430f2fbd3b3b7c68c04c0bbe932cc99fdee5f4 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 17 Jul 2026 17:55:20 +0800 Subject: [PATCH 045/138] feat(spill-local): one-shot startup cleanup for local spill files The local spill backend never reclaimed its files, so configured roots grew without bound and default per-process dsh-spill-* temp roots piled up across runs. Immediate deletion is unsafe because persisted, resumed, and forked sessions may still reference an older locator. Add a fiber-owned, best-effort sweep that runs once after activation (never delaying availability, awaited on disposal): it deletes regular files older than cleanupPeriodDays (default 30; 0 disables) across the configured root and prior default temp roots, prunes emptied dirs, and skips symlinks/unknown entries. Every filesystem failure is contained and logged, so the sweep cannot fail activation or a concurrent write. --- .../2026-07-08-tool-output-spill-files.md | 3 +- ...7-17-local-spill-startup-cleanup.i18n.yaml | 6 + .../2026-07-17-local-spill-startup-cleanup.md | 35 ++ ...26-07-17-local-spill-startup-cleanup.zh.md | 35 ++ docs/config-catalog.md | 11 +- packages/spill/spill-local/README.md | 11 +- packages/spill/spill-local/src/index.ts | 103 +++++- packages/spill/spill-local/src/store.ts | 202 +++++++++++- .../spill-local/tests/spill-local.spec.ts | 309 +++++++++++++++++- 9 files changed, 696 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md create mode 100644 .agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index 14667b74ca..6164066dbb 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -160,7 +160,8 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa - Tool-owned spill for subagent rollouts (`await run.result`, read in-process child session before `run.dispose()`, save JSONL). - Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient. - Remote or database storage backends for ACP or remote environments where a local path is not meaningful. -- Cleanup and retention policy for old spill files, likely tied to session cleanup. + +Cleanup shipped for the local backend as a one-shot startup sweep, not tied to session deletion — see the [startup-cleanup RFC](./2026-07-17-local-spill-startup-cleanup.md). The seam still defines no per-session cleanup policy; retention is a backend concern. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml new file mode 100644 index 0000000000..97d2b5426b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-17-local-spill-startup-cleanup.md: ca4931776f89e641f127072f665e238ca2a1600d +2026-07-17-local-spill-startup-cleanup.zh.md: b90923844ab71e1ce570e8adb66f81aed7bc3488 diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md new file mode 100644 index 0000000000..ca4931776f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md @@ -0,0 +1,35 @@ +# Agent Note: One-shot startup cleanup for local spill files + +Status: implemented + +English | [中文](2026-07-17-local-spill-startup-cleanup.zh.md) + +## Problem + +The local spill backend never deleted the full tool results it wrote. Every oversized result added another file, so configured roots grew without bound and default per-process `dsh-spill-*` roots accumulated across runs. Immediate deletion is wrong because persisted, resumed, and forked sessions may still reference a locator. The [tool output spill policy](./2026-07-08-tool-output-spill-files.md) needs a bounded local-storage lifetime. + +## Decision + +`dsh-spill-local` runs one best-effort cleanup sweep after activation. It does not delay service availability, is owned by the plugin fiber (a single `ctx.effect` whose generator launches the sweep and yields an async disposer that awaits it), and is awaited during disposal so no sweep I/O outlives the fiber. There is no recurring timer and no separate process. + +A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. An invalid value (negative or fractional) throws at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir, deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`, and prunes directories left empty. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn` — the sweep never throws, so it cannot reject activation or a concurrent spill write. Discovery excludes symlinks and non-directories, returning only real `dsh-spill-*` directories the backend could have created. + +The ctx-free mechanics live in `packages/spill/spill-local/src/store.ts` (`sweepSpillRoots`, `discoverDefaultRoots`, `DEFAULT_ROOT_PREFIX`, `isErrno`), unit-testable without a `ctx`; the service in `src/index.ts` owns the config, the cutoff, and the fiber-owned launch/await. + +## Alternatives considered + +**Run a periodic timer.** Rejected because it adds timer lifecycle, overlap control, and another interval knob. A long-lived process may retain files until restart. + +**Delete spills on session disposal.** Rejected because durable sessions, resumes, and forks retain locators. + +**Delete old session directories recursively.** Rejected because a concurrent process may create a fresh spill after the age check. Per-file expiry preserves fresh writes. + +**Tie cleanup to session-persistence deletion.** Rejected because the persistence seam has no common deletion lifecycle, while the local backend also owns independent temporary roots. + +## Consequences + +Cleanup cost the backend a startup sweep and a config knob, and bought a bounded local-storage lifetime without a timer, a daemon, or a session-lifecycle coupling. Concurrent processes may duplicate startup I/O; strict filtering and idempotent file deletion keep this safe. A long-lived process is not cleaned again until restart, and retention deliberately makes old model-visible locators stale only once they age past the cutoff. The seam itself still defines no retention policy — this is a local-backend concern. + +## Testing + +`dsh-spill-local` unit tests cover the age boundary (strictly-older expires, boundary kept), `cleanupPeriodDays: 0` disabling, empty-directory pruning, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage through the real `gatherRoots`/`discoverDefaultRoots` path, active-root de-duplication, load-time validation of a bad `cleanupPeriodDays`, filesystem-failure containment (logged, not thrown) both directly and through the service's `ctx.logger.warn` wiring, and the quiescence contract — activation is available while a barrier-held sweep is parked, and disposal only settles after the sweep finishes. diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md new file mode 100644 index 0000000000..b90923844a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 本地 spill 文件的一次性启动清理 + +Status: implemented + +[English](2026-07-17-local-spill-startup-cleanup.md) | 中文 + +## 问题 + +本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件,因此配置的根目录会无限增长,而每进程默认的 `dsh-spill-*` 根目录也会跨多次运行不断累积。立即删除是错误的,因为已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。[工具输出 spill 策略](./2026-07-08-tool-output-spill-files.md)需要一个有界的本地存储生命周期。 + +## 决策 + +`dsh-spill-local` 在激活后运行一次尽力而为的清理扫描。它不延迟服务可用性,由插件 fiber 拥有(一个 `ctx.effect`,其生成器启动该扫描并让出一个等待它的异步 disposer),并在 dispose 期间被等待,因此没有扫描 I/O 会存活到 fiber 之后。既没有周期性定时器,也没有独立进程。 + +`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。无效值(负数或小数)在加载时抛出。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件,并修剪清空后的目录。它使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。发现过程排除符号链接与非目录,只返回后端可能创建过的真实 `dsh-spill-*` 目录。 + +无 ctx 依赖的机制位于 `packages/spill/spill-local/src/store.ts`(`sweepSpillRoots`、`discoverDefaultRoots`、`DEFAULT_ROOT_PREFIX`、`isErrno`),无需 `ctx` 即可做单元测试;`src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。 + +## 考虑过的替代方案 + +**运行周期性定时器。** 已否决,因为它引入了定时器生命周期、重叠控制以及又一个间隔旋钮。长期运行的进程可能会保留文件直到重启。 + +**在会话 dispose 时删除 spill。** 已否决,因为持久会话、恢复和 fork 都会保留 locator。 + +**递归删除旧的会话目录。** 已否决,因为并发进程可能在年龄检查之后创建一个新的 spill。按文件过期可保留新写入。 + +**将清理绑定到会话持久化删除。** 已否决,因为持久化 seam 没有共同的删除生命周期,而本地后端还独立拥有临时根目录。 + +## 后果 + +清理让后端付出了一次启动扫描和一个配置旋钮的代价,换来了无需定时器、守护进程或会话生命周期耦合的有界本地存储生命周期。并发进程可能重复启动 I/O;严格的过滤与幂等的文件删除保证了这一点的安全。长期运行的进程在重启前不会再次被清理,而这种保留是刻意的——旧的模型可见 locator 只有在超过截止时间后才会失效。seam 本身仍不定义任何保留策略——这是本地后端的关切。 + +## 验证 + +`dsh-spill-local` 单元测试覆盖了年龄边界(严格更旧者过期,边界值保留)、`cleanupPeriodDays: 0` 的禁用、空目录修剪、符号链接/无关条目的跳过、通过真实 `gatherRoots`/`discoverDefaultRoots` 路径对配置根加发现根的覆盖、活动根去重、对错误 `cleanupPeriodDays` 的加载期校验、文件系统失败的兜底(记录而非抛出)——既直接测试,也经由服务的 `ctx.logger.warn` 接线测试——以及静止契约:在一个被屏障挂起的扫描停驻期间激活仍然可用,而 dispose 只有在扫描结束后才会完成。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a845fe22e1..ac7c00738c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2062,10 +2062,19 @@ export interface Config { * a local deployment. Set it to keep spill files under a known location. */ root?: string + /** + * Age in days after which a spill file is eligible for the one-shot startup + * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose + * `mtime` is strictly older than the cutoff are deleted and emptied + * directories are pruned; fresh files, symlinks, and unrelated entries are + * left untouched. Retention is deliberate — a resumed or forked session may + * still reference an older locator until it ages out. + */ + cleanupPeriodDays?: number } ``` -Source: [`packages/spill/spill-local/src/index.ts:22`](../packages/spill/spill-local/src/index.ts) +Source: [`packages/spill/spill-local/src/index.ts:28`](../packages/spill/spill-local/src/index.ts) diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md index 2270a65d92..c97ddf89cd 100644 --- a/packages/spill/spill-local/README.md +++ b/packages/spill/spill-local/README.md @@ -17,8 +17,15 @@ Files land at `/session-/​-`: | Key | Default | Meaning | |---|---|---| | `root` | private 0700 temp dir | Root directory for spill files. Set to keep them under a known location. | +| `cleanupPeriodDays` | `30` | Age in days after which a spill file is eligible for the one-shot startup cleanup sweep. `0` disables cleanup. | -`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design. +## Startup cleanup + +The backend never deletes a spill on the write path — a persisted, resumed, or forked session may still reference an older locator, so immediate deletion would break retrieval. Instead, one best-effort sweep runs **once after activation**: it does not delay service availability, is owned by the plugin fiber, and is awaited on disposal (no sweep I/O outlives the fiber). There is no recurring timer and no separate process, so a long-lived deployment is not swept again until its next restart. + +The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. Within each, it deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays` and prunes any directory left empty. It never follows or deletes a symlink, skips unrelated entries, and contains every filesystem failure (logged, never thrown) so it cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff. + +`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design, and the [startup-cleanup Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md) for the sweep. ## Model Experience @@ -30,5 +37,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Local spill files persist until external cleanup** — the backend has no session-lifecycle deletion or age-based retention policy, because persisted, resumed, and forked sessions may still reference a path. +- **A long-lived deployment is not swept until restart** — the one-shot sweep runs once after activation, so files that age past `cleanupPeriodDays` mid-run are reclaimed only on the next start; there is no recurring timer. - **Locators require a co-located filesystem consumer** — a remote or virtual deployment needs another `SpillStore` backend whose locator and retrieval hint are meaningful there. diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts index 54e2e6cd6d..33948712f0 100644 --- a/packages/spill/spill-local/src/index.ts +++ b/packages/spill/spill-local/src/index.ts @@ -3,20 +3,26 @@ * `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a * private, session-scoped file (see `./store.ts` for the traversal-safe naming * and exclusive owner-only write) and returns a path locator plus local - * read/grep retrieval guidance. + * read/grep retrieval guidance. After activation it runs one best-effort + * startup sweep that reclaims spill files older than `cleanupPeriodDays`. * * @module @deepseek-ai/dsh-spill-local */ import { Context } from '@deepseek-ai/cordis' import { resolve } from 'node:path' +import { tmpdir } from 'node:os' import z from '@deepseek-ai/schemastery' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' -import { privateRoot, saveTextFile } from './store.ts' +import { discoverDefaultRoots, privateRoot, saveTextFile, sweepSpillRoots } from './store.ts' +import type { WarnFn } from './store.ts' -export { encodeSegment, privateRoot, saveTextFile, sessionDir } from './store.ts' -export type { SavedText, SaveTextOptions } from './store.ts' +export { discoverDefaultRoots, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir, sweepSpillRoots, DEFAULT_ROOT_PREFIX } from './store.ts' +export type { SavedText, SaveTextOptions, SweepOptions, WarnFn } from './store.ts' + +/** Milliseconds in one day — converts the `cleanupPeriodDays` config to the sweep cutoff. */ +const MS_PER_DAY = 24 * 60 * 60 * 1000 /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -26,25 +32,114 @@ export interface Config { * a local deployment. Set it to keep spill files under a known location. */ root?: string + /** + * Age in days after which a spill file is eligible for the one-shot startup + * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose + * `mtime` is strictly older than the cutoff are deleted and emptied + * directories are pruned; fresh files, symlinks, and unrelated entries are + * left untouched. Retention is deliberate — a resumed or forked session may + * still reference an older locator until it ages out. + */ + cleanupPeriodDays?: number } +/** The shape after schemastery applied the defaults. */ +type ResolvedConfig = Required> & Pick + /** * Local-filesystem spill backend. Files land under `/session-/…` * with unpredictable names, an exclusive owner-only (0600) write, and a private * (0700) root — a spilled tool result must not be readable by other local users * or redirectable via a planted symlink. + * + * After activation it launches ONE best-effort cleanup sweep (see + * {@link cleanupPeriodDays}) that reclaims expired spill files without delaying + * service availability; the sweep is owned by the plugin fiber and awaited + * during disposal, so a fiber unload never returns before it quiesces. */ export class LocalSpillStore extends SpillStore { static Config: z = z.object({ root: z.string(), + cleanupPeriodDays: z.number().default(30), }) /** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */ readonly root: string + /** Validated config (schemastery applied the `cleanupPeriodDays` default before construction). */ + readonly config: ResolvedConfig + + /** + * The in-flight (or settled) startup cleanup sweep. Held so disposal can await + * it; `undefined` when cleanup is disabled (`cleanupPeriodDays === 0`). + */ + private cleanup: Promise | undefined + constructor(ctx: Context, config: Config) { super(ctx) + // schemastery (static Config) has already filled `cleanupPeriodDays`; the + // cast records that runtime fact for exactOptionalPropertyTypes. + this.config = config as ResolvedConfig + if (!Number.isInteger(this.config.cleanupPeriodDays) || this.config.cleanupPeriodDays < 0) { + throw new Error(`spill-local: cleanupPeriodDays must be a non-negative integer (got ${this.config.cleanupPeriodDays})`) + } this.root = config.root !== undefined ? resolve(config.root) : privateRoot() + + // One best-effort startup sweep, owned by the fiber. The generator body runs + // at activation but does NOT await the sweep — it launches it and yields an + // async disposer that awaits the SAME promise, so service availability is + // never delayed yet a fiber unload reaches quiescence (no sweep I/O outlives + // the fiber). Disabled (`cleanupPeriodDays === 0`) yields a no-op disposer. + ctx.effect(function* (this: LocalSpillStore) { + if (this.config.cleanupPeriodDays > 0) { + const warn: WarnFn = (message) => { this.ctx.logger.warn(message) } + this.cleanup = this.runCleanup(warn) + } + yield async () => { await this.cleanup } + }.bind(this), 'spill-local cleanup sweep') + } + + /** + * Run the one-shot cleanup: gather the roots to sweep (see {@link gatherRoots}) + * and sweep all of them at the age cutoff. Best-effort — + * {@link sweepSpillRoots} contains every filesystem failure, so this never + * rejects and cannot fail activation or a concurrent spill write. + * + * @param warn - sink for a contained filesystem failure. + * @returns Resolves when the sweep finishes (never rejects). + */ + private async runCleanup(warn: WarnFn): Promise { + const cutoffMs = Date.now() - this.config.cleanupPeriodDays * MS_PER_DAY + const roots = await this.gatherRoots(warn) + await sweepSpillRoots({ roots, cutoffMs, warn }) + } + + /** + * The roots the startup sweep covers: the prior default `dsh-spill-*` temp + * roots (see {@link discoverDefaultRoots}) plus the configured/active root, + * de-duplicated (the active root may itself be a discovered default). A test + * overrides this to inject an isolated root set — and, being the sweep's one + * async gather point, to hold the sweep open across a disposal for the + * quiescence check; it is a test seam, not a deployment knob. + * + * @param warn - sink for a contained discovery failure. + * @returns The absolute roots to sweep. + */ + protected async gatherRoots(warn: WarnFn): Promise { + const discovered = await discoverDefaultRoots(warn, this.defaultRootsBase()) + return discovered.includes(this.root) ? discovered : [...discovered, this.root] + } + + /** + * The directory scanned for prior default `dsh-spill-*` roots — the OS tmpdir, + * where {@link privateRoot} creates them (accumulation only happens there). A + * test overrides this to point discovery at an isolated fixture instead of the + * real tmpdir; it is a test seam, not a deployment knob. + * + * @returns The base directory to scan for default spill roots. + */ + protected defaultRootsBase(): string { + return tmpdir() } async saveText(input: SaveTextSpill): Promise { diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index e4451d5268..431e4840c6 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -8,10 +8,18 @@ import { createHash, randomBytes } from 'node:crypto' import { mkdtempSync } from 'node:fs' -import { mkdir, open } from 'node:fs/promises' +import { lstat, mkdir, open, readdir, rmdir, unlink } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' +/** + * Filename prefix for the lazily-created private default spill roots + * (`mkdtemp(tmpdir()/dsh-spill-)`). Startup cleanup rediscovers these + * per-process roots (from prior runs that used no configured `root`) by this + * prefix — see {@link discoverDefaultRoots}. + */ +export const DEFAULT_ROOT_PREFIX = 'dsh-spill-' + let defaultRoot: string | undefined /** @@ -23,7 +31,7 @@ let defaultRoot: string | undefined * @returns The lazily-created private spill root. */ export function privateRoot(): string { - defaultRoot ??= mkdtempSync(join(tmpdir(), 'dsh-spill-')) + defaultRoot ??= mkdtempSync(join(tmpdir(), DEFAULT_ROOT_PREFIX)) return defaultRoot } @@ -114,3 +122,193 @@ export async function saveTextFile(options: SaveTextOptions): Promise } return { path, bytes } } + +/** A one-argument warning sink — the sweep's only side effect on failure (never throws). */ +export type WarnFn = (message: string) => void + +/** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */ +export interface SweepOptions { + /** Absolute spill roots to sweep (configured root and/or discovered default roots). */ + roots: string[] + /** + * Epoch-millis cutoff: a regular file is deleted when its `mtime` is strictly + * older than this. The caller derives it from `now - cleanupPeriodDays`, so a + * file written exactly at the boundary is kept (only strictly-older expires). + */ + cutoffMs: number + /** Where a contained filesystem failure is reported; the sweep itself never throws. */ + warn: WarnFn +} + +/** + * Delete a single path, treating a concurrent-race disappearance as success. + * A parallel process (or another sweep) may `unlink` the same file between our + * scan and our own `unlink` — ENOENT then means the goal (file gone) already + * holds, so it is not a failure. Any other error is reported and swallowed. + * + * @param path The absolute file path to remove. + * @param warn Sink for a non-ENOENT failure message. + * @returns Resolves once the removal was attempted (never rejects). + */ +async function unlinkIdempotent(path: string, warn: WarnFn): Promise { + try { + await unlink(path) + } catch (error: unknown) { + /* v8 ignore start -- reached only when a file selected for deletion (a + regular file that passed lstat) then fails to unlink: either it raced away + (ENOENT) or a permission/IO fault struck between the stat and the unlink. + Neither is deterministically reproducible in-process. */ + if (isErrno(error, 'ENOENT')) return + warn(`spill-local: failed to delete ${path}: ${String(error)}`) + /* v8 ignore stop */ + } +} + +/** + * True when `error` is a Node system error carrying the given `code`. + * + * @param error The caught value to test. + * @param code The `NodeJS.ErrnoException` code to match (e.g. `'ENOENT'`). + * @returns `true` when `error` is an `Error` whose `code` equals `code`. + */ +export function isErrno(error: unknown, code: string): boolean { + return error instanceof Error && (error as NodeJS.ErrnoException).code === code +} + +/** + * Sweep one spill session directory: delete expired regular files, skip + * everything else, and report the directory empty afterward so the caller can + * prune it. A symlink or any non-regular entry (socket, fifo, nested dir) is + * left untouched — `lstat` never follows a link, so a planted symlink can + * neither be deleted nor redirect the age check. Every per-entry failure is + * contained: one unreadable file does not abort the directory. + * + * @param dir The absolute session directory to scan. + * @param cutoffMs Files with `mtime` strictly older than this are deleted. + * @param warn Sink for contained filesystem failures. + * @returns `true` when the directory holds no entries after the sweep (a prune candidate). + */ +async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Promise { + let names: string[] + try { + names = await readdir(dir) + } catch (error: unknown) { + // A `session-*` entry that is not a readable directory (a stray file, or an + // unreadable/vanished dir) is not ours to fix — report and leave it. False + // keeps it out of the prune step. + warn(`spill-local: failed to read ${dir}: ${String(error)}`) + return false + } + let remaining = names.length + for (const name of names) { + const path = join(dir, name) + let stats + try { + stats = await lstat(path) + } catch (error: unknown) { + /* v8 ignore start -- an entry that readdir just returned then fails to + lstat only by racing away (ENOENT) or a permission/IO fault; keep it out + of the deterministic test surface. */ + if (isErrno(error, 'ENOENT')) { remaining--; continue } + warn(`spill-local: failed to stat ${path}: ${String(error)}`) + continue + /* v8 ignore stop */ + } + // Only regular files expire. Symlinks and other special entries are skipped + // (never followed) so the sweep cannot be redirected or delete a link. + if (!stats.isFile()) continue + if (stats.mtimeMs >= cutoffMs) continue + await unlinkIdempotent(path, warn) + remaining-- + } + return remaining === 0 +} + +/** + * Best-effort one-shot cleanup: across each root, delete expired regular files + * under its `session-*` directories and prune any directory left empty. The + * sweep is idempotent and safe to run concurrently with live spill writes and + * with another process's sweep — per-file expiry preserves a fresh write even + * if it lands mid-sweep, and every filesystem failure is caught and reported + * rather than thrown, so a caller can await this during activation/disposal + * without it ever rejecting. + * + * @param options The roots to sweep, the age cutoff, and the failure sink. + * @returns Resolves when the sweep finishes (never rejects). + */ +export async function sweepSpillRoots(options: SweepOptions): Promise { + const { roots, cutoffMs, warn } = options + for (const root of roots) { + let entries: string[] + try { + entries = await readdir(root) + } catch (error: unknown) { + // A root that does not exist yet (no spill ever written) is the common + // case, not an error: ENOENT is silent, anything else is reported. + if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to read root ${root}: ${String(error)}`) + continue + } + for (const name of entries) { + // Only the backend's own `session-` directories are swept; an + // unrelated sibling under a shared configured root is left untouched. + if (!name.startsWith('session-')) continue + const dir = join(root, name) + const empty = await sweepSessionDir(dir, cutoffMs, warn) + if (!empty) continue + try { + await rmdir(dir) + } catch (error: unknown) { + /* v8 ignore start -- prune runs only on a dir observed empty; a failure + here means a concurrent writer added a file (ENOTEMPTY) or a + permission/IO fault struck — both are races outside deterministic + in-process testing. */ + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { + warn(`spill-local: failed to prune ${dir}: ${String(error)}`) + } + /* v8 ignore stop */ + } + } + } +} + +/** + * Discover prior default spill roots: the `dsh-spill-*` directories directly + * under `base` (the OS tmpdir) that earlier runs created via {@link privateRoot} + * when no `root` was configured. A long-lived deployment with a configured root + * will find none; a series of default-root runs accumulates one per process, so + * the startup sweep reclaims them all. Symlinks and non-directories are excluded + * — only real directories the backend could have created are returned. + * + * @param warn Sink for a failure reading `base` (returns `[]` on failure). + * @param base The directory to scan; defaults to the OS tmpdir (a test seam). + * @returns Absolute paths of the discovered default roots (possibly empty). + */ +export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise { + let entries: string[] + try { + entries = await readdir(base) + } catch (error: unknown) { + warn(`spill-local: failed to scan ${base} for default roots: ${String(error)}`) + return [] + } + const roots: string[] = [] + for (const name of entries) { + if (!name.startsWith(DEFAULT_ROOT_PREFIX)) continue + const path = join(base, name) + let stats + try { + // lstat, not stat: a symlink named `dsh-spill-*` must not be treated as a + // root we then sweep (it could point anywhere). + stats = await lstat(path) + } catch (error: unknown) { + /* v8 ignore start -- an entry readdir just returned fails to lstat only by + racing away (ENOENT) or a permission/IO fault; not deterministically + reproducible. */ + if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to stat default root ${path}: ${String(error)}`) + continue + /* v8 ignore stop */ + } + if (stats.isDirectory()) roots.push(path) + } + return roots +} diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index fd01babeff..8e632c885d 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -2,19 +2,33 @@ * Tests for the LOCAL spill backend: `saveText` writes a session-scoped file and * returns a locator + byte length + retrieval hint, filename sanitization * neutralizes traversal, the configured `root` is honored (and the private - * default when omitted), and a storage failure rejects. The Cordis-free - * `store.ts` helpers are exercised directly for the naming/encoding edge cases. + * default when omitted), and a storage failure rejects. The startup cleanup + * sweep expires old files, prunes empty dirs, skips symlinks/unknown entries, + * discovers prior default roots, contains filesystem failures, and is awaited on + * disposal without blocking activation. The Cordis-free `store.ts` helpers are + * exercised directly for the naming/encoding and sweep edge cases. */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, normalize } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SaveTextSpill } from '@deepseek-ai/dsh-spill' -import LocalSpillStore, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local' +import LocalSpillStore, { + DEFAULT_ROOT_PREFIX, + discoverDefaultRoots, + encodeSegment, + isErrno, + privateRoot, + saveTextFile, + sessionDir, + sweepSpillRoots, +} from '@deepseek-ai/dsh-spill-local' + +const DAY_MS = 24 * 60 * 60 * 1000 let root: string @@ -25,6 +39,13 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) +/** Write a file with an mtime `ageDays` in the past (fractional allowed). */ +function writeAged(path: string, content: string, ageDays: number): void { + writeFileSync(path, content) + const when = (Date.now() - ageDays * DAY_MS) / 1000 + utimesSync(path, when, when) +} + function request(overrides: Partial = {}): SaveTextSpill { return { owner: { sessionId: SessionId('sess-1') }, @@ -113,9 +134,11 @@ describe('privateRoot', () => { }) describe('LocalSpillStore service', () => { + // These tests exercise save/root resolution, not cleanup; disabling the sweep + // (cleanupPeriodDays: 0) keeps them from scanning/sweeping the real tmpdir. it('registers as ctx.spillStore and saves under the configured root', async () => { const ctx = new Context() - await ctx.plugin(LocalSpillStore, { root }) + await ctx.plugin(LocalSpillStore, { root, cleanupPeriodDays: 0 }) const ref = await ctx.spillStore.saveText(request()) expect(dirname(ref.locator)).toBe(sessionDir(root, 'sess-1')) expect(readFileSync(ref.locator, 'utf8')).toBe('the full body') @@ -125,13 +148,13 @@ describe('LocalSpillStore service', () => { it('resolves a relative configured root to absolute', async () => { const ctx = new Context() - await ctx.plugin(LocalSpillStore, { root: '.' }) + await ctx.plugin(LocalSpillStore, { root: '.', cleanupPeriodDays: 0 }) expect(isAbsolute((ctx.spillStore as LocalSpillStore).root)).toBe(true) }) it('falls back to the private root when none is configured', async () => { const ctx = new Context() - await ctx.plugin(LocalSpillStore, {}) + await ctx.plugin(LocalSpillStore, { cleanupPeriodDays: 0 }) expect((ctx.spillStore as LocalSpillStore).root).toBe(privateRoot()) }) @@ -139,7 +162,275 @@ describe('LocalSpillStore service', () => { const ctx = new Context() // A file (not a dir) as the root makes mkdir under it fail — a real storage error. const filePath = (await saveTextFile({ root, sessionId: 's', suggestedName: 'f', content: 'x' })).path - await ctx.plugin(LocalSpillStore, { root: filePath }) + await ctx.plugin(LocalSpillStore, { root: filePath, cleanupPeriodDays: 0 }) await expect(ctx.spillStore.saveText(request())).rejects.toThrow() }) + + it('rejects a negative or fractional cleanupPeriodDays at load', async () => { + await expect(new Context().plugin(LocalSpillStore, { root, cleanupPeriodDays: -1 })) + .rejects.toThrow(/cleanupPeriodDays must be a non-negative integer/) + await expect(new Context().plugin(LocalSpillStore, { root, cleanupPeriodDays: 1.5 })) + .rejects.toThrow(/cleanupPeriodDays must be a non-negative integer/) + }) + + it('defaults cleanupPeriodDays to 30', async () => { + const ctx = new Context() + // Point discovery at an empty isolated base so the default sweep does not + // touch the real tmpdir; assert only that the default landed on config. + const emptyBase = mkdtempSync(join(tmpdir(), 'dsh-empty-')) + class Isolated extends LocalSpillStore { + protected override defaultRootsBase(): string { return emptyBase } + } + try { + const fiber = await ctx.plugin(Isolated, { root }) + const store = ctx.spillStore as LocalSpillStore + await fiber.dispose() + expect(store.config.cleanupPeriodDays).toBe(30) + } finally { + rmSync(emptyBase, { recursive: true, force: true }) + } + }) + + it('the default discovery base is the OS tmpdir', async () => { + // Every hermetic sweep test overrides defaultRootsBase(); pin its production + // default here (scan the OS tmpdir) without letting the sweep touch tmpdir. + class Exposed extends LocalSpillStore { + base(): string { return this.defaultRootsBase() } + protected override async gatherRoots(): Promise { return [] } + } + const ctx = new Context() + const fiber = await ctx.plugin(Exposed, { root, cleanupPeriodDays: 30 }) + const store = ctx.spillStore as Exposed + await fiber.dispose() + expect(store.base()).toBe(tmpdir()) + }) + + it('routes a sweep filesystem failure to ctx.logger.warn (service warn wiring)', async () => { + // A `session-*` entry that is a FILE, not a directory, makes readdir throw + // ENOTDIR inside the real sweep. The service's warn closure must forward it + // to ctx.logger.warn, and disposal must still settle cleanly. + const stray = join(root, 'session-stray'); writeFileSync(stray, 'x') + const ctx = new Context() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + class Discovering extends LocalSpillStore { + protected override async gatherRoots(): Promise { return [this.root] } + } + const fiber = await ctx.plugin(Discovering, { root, cleanupPeriodDays: 30 }) + await fiber.dispose() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read')) + }) }) + +/** + * A store whose sweep covers exactly the roots handed in (no real-tmpdir scan) — + * the hermetic seam for the cleanup tests. `barrier`, when set, holds the async + * gather open so a test can prove disposal awaits the sweep. + */ +class SweptStore extends LocalSpillStore { + static sweepRoots: string[] = [] + static barrier: Promise | undefined + protected override async gatherRoots(): Promise { + if (SweptStore.barrier) await SweptStore.barrier + return SweptStore.sweepRoots + } +} + +async function runSweep(roots: string[], cleanupPeriodDays = 30): Promise { + SweptStore.sweepRoots = roots + SweptStore.barrier = undefined + const ctx = new Context() + const fiber = await ctx.plugin(SweptStore, { root, cleanupPeriodDays }) + // Disposal awaits the fiber-owned sweep, so after this the sweep has run. + await fiber.dispose() +} + +describe('startup cleanup sweep', () => { + it('deletes files older than the cutoff and keeps fresh ones', async () => { + const dir = sessionDir(root, 'sess-1') + mkdirSync(dir, { recursive: true }) + const old = join(dir, 'old.txt'); writeAged(old, 'x', 40) + const fresh = join(dir, 'fresh.txt'); writeAged(fresh, 'y', 1) + await runSweep([root]) + expect(existsSync(old)).toBe(false) + expect(existsSync(fresh)).toBe(true) + }) + + it('keeps a file exactly at the boundary (only strictly-older expires)', async () => { + const dir = sessionDir(root, 'sess-1') + mkdirSync(dir, { recursive: true }) + // mtime == cutoff: mtimeMs >= cutoffMs holds, so it is kept. Age it just + // under 30d to avoid the sub-millisecond race of "exactly now - 30d". + const boundary = join(dir, 'boundary.txt'); writeAged(boundary, 'x', 29.9) + await runSweep([root]) + expect(existsSync(boundary)).toBe(true) + }) + + it('disabled (cleanupPeriodDays: 0) sweeps nothing', async () => { + const dir = sessionDir(root, 'sess-1') + mkdirSync(dir, { recursive: true }) + const old = join(dir, 'old.txt'); writeAged(old, 'x', 400) + await runSweep([root], 0) + expect(existsSync(old)).toBe(true) + }) + + it('prunes a directory left empty, keeps one with a surviving file', async () => { + const emptied = sessionDir(root, 'emptied') + const kept = sessionDir(root, 'kept') + mkdirSync(emptied, { recursive: true }) + mkdirSync(kept, { recursive: true }) + writeAged(join(emptied, 'a.txt'), 'x', 40) + writeAged(join(kept, 'fresh.txt'), 'y', 1) + await runSweep([root]) + expect(existsSync(emptied)).toBe(false) + expect(existsSync(kept)).toBe(true) + }) + + it('skips symlinks and non-session entries', async () => { + const dir = sessionDir(root, 'sess-1') + mkdirSync(dir, { recursive: true }) + // A symlink pointing at an old target must NOT be followed or deleted. + const target = join(root, 'target.txt'); writeAged(target, 'keep', 40) + const link = join(dir, 'link.txt'); symlinkSync(target, link) + // A non-session sibling directory under a shared root is untouched. + const unrelated = join(root, 'not-a-session'); mkdirSync(unrelated) + const unrelatedOld = join(unrelated, 'old.txt'); writeAged(unrelatedOld, 'x', 40) + await runSweep([root]) + // The symlink itself survives (lstat sees a link, not a file), so its dir is + // not empty and is not pruned; the link target survives too. + expect(existsSync(link)).toBe(true) + expect(existsSync(target)).toBe(true) + expect(existsSync(unrelatedOld)).toBe(true) + }) + + it('covers the configured root AND discovered default roots (real gatherRoots)', async () => { + // A prior default root under an isolated fake tmpdir + the configured root. + // This test drives the REAL gatherRoots/discoverDefaultRoots path by seaming + // only the tmpdir scan base, not gatherRoots itself. + const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-')) + const priorDefault = join(fakeTmp, `${DEFAULT_ROOT_PREFIX}ABCDEF`) + const priorDir = sessionDir(priorDefault, 'old-sess') + mkdirSync(priorDir, { recursive: true }) + const priorOld = join(priorDir, 'old.txt'); writeAged(priorOld, 'x', 40) + const cfgDir = sessionDir(root, 'sess-1') + mkdirSync(cfgDir, { recursive: true }) + const cfgOld = join(cfgDir, 'old.txt'); writeAged(cfgOld, 'x', 40) + class Discovering extends LocalSpillStore { + protected override defaultRootsBase(): string { return fakeTmp } + } + try { + const ctx = new Context() + const fiber = await ctx.plugin(Discovering, { root, cleanupPeriodDays: 30 }) + await fiber.dispose() + expect(existsSync(priorOld)).toBe(false) + expect(existsSync(cfgOld)).toBe(false) + } finally { + rmSync(fakeTmp, { recursive: true, force: true }) + } + }) + + it('de-dups when the active root is itself a discovered default (real gatherRoots)', async () => { + // The configured root lives directly under the seamed base and matches the + // default prefix, so discovery finds it AND it is the active root — the sweep + // must run once, not choke on the duplicate. + const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-')) + const activeDefault = join(fakeTmp, `${DEFAULT_ROOT_PREFIX}ACTIVE`) + const dir = sessionDir(activeDefault, 'sess-1') + mkdirSync(dir, { recursive: true }) + const old = join(dir, 'old.txt'); writeAged(old, 'x', 40) + class Discovering extends LocalSpillStore { + protected override defaultRootsBase(): string { return fakeTmp } + } + try { + const ctx = new Context() + const fiber = await ctx.plugin(Discovering, { root: activeDefault, cleanupPeriodDays: 30 }) + await fiber.dispose() + expect(existsSync(old)).toBe(false) + } finally { + rmSync(fakeTmp, { recursive: true, force: true }) + } + }) + + it('does not block activation but is awaited on disposal (quiescence)', async () => { + const dir = sessionDir(root, 'sess-1') + mkdirSync(dir, { recursive: true }) + const old = join(dir, 'old.txt'); writeAged(old, 'x', 40) + + // Hold the sweep open behind a barrier we control. + let release!: () => void + SweptStore.sweepRoots = [root] + SweptStore.barrier = new Promise((resolve) => { release = resolve }) + + const ctx = new Context() + const fiber = await ctx.plugin(SweptStore, { root, cleanupPeriodDays: 30 }) + // Activation returned while the sweep is still parked: service is usable and + // the old file is untouched so far. + expect(existsSync(old)).toBe(true) + const ref = await ctx.spillStore.saveText(request()) + expect(readFileSync(ref.locator, 'utf8')).toBe('the full body') + + // Disposal must AWAIT the sweep: release the barrier, and dispose only + // settles after the sweep deleted the old file. + release() + await fiber.dispose() + expect(existsSync(old)).toBe(false) + }) + + it('a filesystem failure is contained (logged, never thrown) and does not fail a spill write', async () => { + const warn = vi.fn() + // A path that is a FILE, not a directory: readdir(root) throws ENOTDIR. The + // sweep must log and return, never reject. + const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x') + await expect(sweepSpillRoots({ roots: [filePath], cutoffMs: Date.now(), warn })).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root')) + }) + + it('a nonexistent root is silent (the common no-spill-yet case)', async () => { + const warn = vi.fn() + await sweepSpillRoots({ roots: [join(root, 'never-created')], cutoffMs: Date.now(), warn }) + expect(warn).not.toHaveBeenCalled() + }) + + it('a session entry that is a file (not a dir) is reported, not pruned', async () => { + const warn = vi.fn() + // `session-strayfile` matches the session- prefix but is a regular file, so + // readdir on it throws ENOTDIR: reported, left in place (not empty → no prune). + const stray = join(root, 'session-strayfile'); writeFileSync(stray, 'x') + await sweepSpillRoots({ roots: [root], cutoffMs: Date.now(), warn }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read')) + expect(existsSync(stray)).toBe(true) + }) +}) + +describe('discoverDefaultRoots', () => { + it('returns only real dsh-spill-* directories, excluding symlinks and non-matches', async () => { + const base = mkdtempSync(join(tmpdir(), 'dsh-disc-')) + try { + const realRoot = join(base, `${DEFAULT_ROOT_PREFIX}real`); mkdirSync(realRoot) + mkdirSync(join(base, 'unrelated-dir')) + writeFileSync(join(base, `${DEFAULT_ROOT_PREFIX}file`), 'x') // matches prefix but is a file + symlinkSync(realRoot, join(base, `${DEFAULT_ROOT_PREFIX}link`)) // matches prefix but is a symlink + const found = await discoverDefaultRoots(() => {}, base) + expect(found).toEqual([realRoot]) + } finally { + rmSync(base, { recursive: true, force: true }) + } + }) + + it('returns [] and warns when the base is unreadable', async () => { + const warn = vi.fn() + const missing = join(root, 'no-such-base') + expect(await discoverDefaultRoots(warn, missing)).toEqual([]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to scan')) + }) +}) + +describe('isErrno', () => { + it('matches a Node system error by code and rejects non-matches', () => { + const err = Object.assign(new Error('boom'), { code: 'ENOENT' }) + expect(isErrno(err, 'ENOENT')).toBe(true) + expect(isErrno(err, 'EPERM')).toBe(false) + expect(isErrno('not an error', 'ENOENT')).toBe(false) + expect(isErrno(new Error('no code'), 'ENOENT')).toBe(false) + }) +}) + From c6a4de620750fa1b84b5b54a121269ec9dad555f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 20 Jul 2026 12:03:57 +0800 Subject: [PATCH 046/138] fix(spill-local): exact-shape root/session matching and prune discovered roots Tighten the startup sweep to backend-generated name shapes and fix the tests that had drifted from the SweepRoot-based API: - Match roots by the exact `dsh-spill-<6>` mkdtemp shape and session dirs by `session-<12 hex>` (DEFAULT_ROOT_RE / SESSION_DIR_RE), replacing loose startsWith checks so foreign or fixture-shaped directories are never swept. - Carry `SweepRoot { path, pruneWhenEmpty }` through SweepOptions so a discovered prior-default root is removed once emptied while the active root is never pruned; lstat each session entry so a symlinked session dir is not followed. - Fix the tests to the SweepRoot API: import SweepRoot, correct the gatherRoots override return shapes, build discovery fixtures with the real mkdtemp shape, and route the warn-wiring test through a deterministic failure path. --- packages/spill/spill-local/src/index.ts | 22 ++- packages/spill/spill-local/src/store.ts | 111 +++++++++++--- .../spill-local/tests/spill-local.spec.ts | 143 +++++++++++++----- 3 files changed, 210 insertions(+), 66 deletions(-) diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts index 33948712f0..7c4069c539 100644 --- a/packages/spill/spill-local/src/index.ts +++ b/packages/spill/spill-local/src/index.ts @@ -16,10 +16,10 @@ import z from '@deepseek-ai/schemastery' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import { discoverDefaultRoots, privateRoot, saveTextFile, sweepSpillRoots } from './store.ts' -import type { WarnFn } from './store.ts' +import type { SweepRoot, WarnFn } from './store.ts' export { discoverDefaultRoots, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir, sweepSpillRoots, DEFAULT_ROOT_PREFIX } from './store.ts' -export type { SavedText, SaveTextOptions, SweepOptions, WarnFn } from './store.ts' +export type { SavedText, SaveTextOptions, SweepOptions, SweepRoot, WarnFn } from './store.ts' /** Milliseconds in one day — converts the `cleanupPeriodDays` config to the sweep cutoff. */ const MS_PER_DAY = 24 * 60 * 60 * 1000 @@ -115,19 +115,25 @@ export class LocalSpillStore extends SpillStore { } /** - * The roots the startup sweep covers: the prior default `dsh-spill-*` temp - * roots (see {@link discoverDefaultRoots}) plus the configured/active root, - * de-duplicated (the active root may itself be a discovered default). A test + * The roots the startup sweep covers: each discovered prior-default + * `dsh-spill-*` temp root (see {@link discoverDefaultRoots}), pruned when + * emptied, plus the active/configured root, which is swept but NEVER pruned + * (the live process is still writing into it). The active root is de-duped out + * of the discovered set so it is not swept twice or marked prunable. A test * overrides this to inject an isolated root set — and, being the sweep's one * async gather point, to hold the sweep open across a disposal for the * quiescence check; it is a test seam, not a deployment knob. * * @param warn - sink for a contained discovery failure. - * @returns The absolute roots to sweep. + * @returns The roots to sweep, each flagged for prune-when-empty. */ - protected async gatherRoots(warn: WarnFn): Promise { + protected async gatherRoots(warn: WarnFn): Promise { const discovered = await discoverDefaultRoots(warn, this.defaultRootsBase()) - return discovered.includes(this.root) ? discovered : [...discovered, this.root] + const roots: SweepRoot[] = discovered + .filter(path => path !== this.root) + .map(path => ({ path, pruneWhenEmpty: true })) + roots.push({ path: this.root, pruneWhenEmpty: false }) + return roots } /** diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index 431e4840c6..01528fd573 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -20,6 +20,23 @@ import { tmpdir } from 'node:os' */ export const DEFAULT_ROOT_PREFIX = 'dsh-spill-' +/** + * A backend-generated default root name: `dsh-spill-` plus the 6-character + * suffix `mkdtemp` appends (see {@link privateRoot}). Discovery matches this + * EXACT shape, not the bare prefix, so an unrelated `dsh-spill-test-*` fixture + * or a foreign tool's differently-shaped `dsh-spill-…` directory is never + * mistaken for a backend root to sweep. + */ +const DEFAULT_ROOT_RE = /^dsh-spill-[A-Za-z0-9]{6}$/ + +/** + * A backend-generated session directory name: `session-` plus the 12 lowercase + * hex characters {@link sessionDir} derives from `sha256(sessionId)`. The sweep + * only descends into entries of this EXACT shape, so an unrelated + * `session-backup` directory under a shared configured root is never swept. + */ +const SESSION_DIR_RE = /^session-[0-9a-f]{12}$/ + let defaultRoot: string | undefined /** @@ -126,10 +143,23 @@ export async function saveTextFile(options: SaveTextOptions): Promise /** A one-argument warning sink — the sweep's only side effect on failure (never throws). */ export type WarnFn = (message: string) => void +/** One root to sweep, plus whether an emptied root directory should itself be pruned. */ +export interface SweepRoot { + /** Absolute spill root to sweep. */ + path: string + /** + * When `true`, remove the root directory itself once its last `session-*` + * child is pruned. Set for DISCOVERED prior-default `dsh-spill-*` roots (one + * per past process — otherwise they accumulate empty forever), never for the + * active/configured root the live process is still writing into. + */ + pruneWhenEmpty: boolean +} + /** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */ export interface SweepOptions { - /** Absolute spill roots to sweep (configured root and/or discovered default roots). */ - roots: string[] + /** Roots to sweep (configured/active root and/or discovered prior-default roots). */ + roots: SweepRoot[] /** * Epoch-millis cutoff: a regular file is deleted when its `mtime` is strictly * older than this. The caller derives it from `now - cleanupPeriodDays`, so a @@ -178,12 +208,14 @@ export function isErrno(error: unknown, code: string): boolean { /** * Sweep one spill session directory: delete expired regular files, skip * everything else, and report the directory empty afterward so the caller can - * prune it. A symlink or any non-regular entry (socket, fifo, nested dir) is - * left untouched — `lstat` never follows a link, so a planted symlink can - * neither be deleted nor redirect the age check. Every per-entry failure is + * prune it. The `dir` entry MUST be a real directory — the caller `lstat`s it + * first and skips a symlink, so this never follows a `session-*` symlink into a + * foreign tree. Inside, a symlink or any non-regular entry (socket, fifo, nested + * dir) is left untouched — `lstat` never follows a link, so a planted symlink + * can neither be deleted nor redirect the age check. Every per-entry failure is * contained: one unreadable file does not abort the directory. * - * @param dir The absolute session directory to scan. + * @param dir The absolute session directory to scan (already confirmed a real dir). * @param cutoffMs Files with `mtime` strictly older than this are deleted. * @param warn Sink for contained filesystem failures. * @returns `true` when the directory holds no entries after the sweep (a prune candidate). @@ -241,20 +273,39 @@ export async function sweepSpillRoots(options: SweepOptions): Promise { for (const root of roots) { let entries: string[] try { - entries = await readdir(root) + entries = await readdir(root.path) } catch (error: unknown) { // A root that does not exist yet (no spill ever written) is the common // case, not an error: ENOENT is silent, anything else is reported. - if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to read root ${root}: ${String(error)}`) + if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to read root ${root.path}: ${String(error)}`) continue } + // Track whether the root holds ANY entry the sweep did not fully reclaim, so + // a discovered prior-default root can be pruned only when nothing remains. + let rootEmptiable = true for (const name of entries) { - // Only the backend's own `session-` directories are swept; an - // unrelated sibling under a shared configured root is left untouched. - if (!name.startsWith('session-')) continue - const dir = join(root, name) + // Only the backend's own `session-<12 hex>` directories are swept; an + // unrelated sibling (`session-backup`, a stray file) is left untouched and + // blocks pruning the root. + if (!SESSION_DIR_RE.test(name)) { rootEmptiable = false; continue } + const dir = join(root.path, name) + let stats + try { + // lstat the session entry itself: a `session-*` SYMLINK must never be + // followed (readdir/unlink through it would delete files in a foreign + // target). Only a real directory is swept. + stats = await lstat(dir) + } catch (error: unknown) { + /* v8 ignore start -- an entry readdir just returned fails to lstat only + by racing away (ENOENT) or a permission/IO fault; not deterministically + reproducible. */ + if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to stat ${dir}: ${String(error)}`) + continue + /* v8 ignore stop */ + } + if (!stats.isDirectory()) { rootEmptiable = false; continue } const empty = await sweepSessionDir(dir, cutoffMs, warn) - if (!empty) continue + if (!empty) { rootEmptiable = false; continue } try { await rmdir(dir) } catch (error: unknown) { @@ -262,22 +313,42 @@ export async function sweepSpillRoots(options: SweepOptions): Promise { here means a concurrent writer added a file (ENOTEMPTY) or a permission/IO fault struck — both are races outside deterministic in-process testing. */ + rootEmptiable = false if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { warn(`spill-local: failed to prune ${dir}: ${String(error)}`) } /* v8 ignore stop */ } } + // A discovered prior-default root (one per past process) is removed once its + // last session dir is gone — otherwise empty roots accumulate forever and + // every future startup rescans them. The active/configured root is never + // pruned (the live process is still writing into it). + if (root.pruneWhenEmpty && rootEmptiable) { + try { + await rmdir(root.path) + } catch (error: unknown) { + // A concurrent process may have written a fresh spill into this root + // after our scan (ENOTEMPTY), or removed it already (ENOENT) — benign + // races. Anything else is reported. + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { + warn(`spill-local: failed to prune root ${root.path}: ${String(error)}`) + } + } + } } } /** - * Discover prior default spill roots: the `dsh-spill-*` directories directly - * under `base` (the OS tmpdir) that earlier runs created via {@link privateRoot} - * when no `root` was configured. A long-lived deployment with a configured root - * will find none; a series of default-root runs accumulates one per process, so - * the startup sweep reclaims them all. Symlinks and non-directories are excluded - * — only real directories the backend could have created are returned. + * Discover prior default spill roots: the `dsh-spill-<6 chars>` directories + * directly under `base` (the OS tmpdir) that earlier runs created via + * {@link privateRoot} when no `root` was configured. A long-lived deployment + * with a configured root will find none; a series of default-root runs + * accumulates one per process, so the startup sweep reclaims them all. Matching + * is the EXACT `mkdtemp` shape (see {@link DEFAULT_ROOT_RE}), not the bare + * prefix, so an unrelated `dsh-spill-test-*` fixture or a foreign + * differently-shaped directory is never swept; symlinks and non-directories are + * excluded too — only real directories the backend could have created. * * @param warn Sink for a failure reading `base` (returns `[]` on failure). * @param base The directory to scan; defaults to the OS tmpdir (a test seam). @@ -293,7 +364,7 @@ export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir() } const roots: string[] = [] for (const name of entries) { - if (!name.startsWith(DEFAULT_ROOT_PREFIX)) continue + if (!DEFAULT_ROOT_RE.test(name)) continue const path = join(base, name) let stats try { diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index 8e632c885d..41d35824c6 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -27,6 +27,7 @@ import LocalSpillStore, { sessionDir, sweepSpillRoots, } from '@deepseek-ai/dsh-spill-local' +import type { SweepRoot } from '@deepseek-ai/dsh-spill-local' const DAY_MS = 24 * 60 * 60 * 1000 @@ -196,7 +197,7 @@ describe('LocalSpillStore service', () => { // default here (scan the OS tmpdir) without letting the sweep touch tmpdir. class Exposed extends LocalSpillStore { base(): string { return this.defaultRootsBase() } - protected override async gatherRoots(): Promise { return [] } + protected override async gatherRoots(): Promise { return [] } } const ctx = new Context() const fiber = await ctx.plugin(Exposed, { root, cleanupPeriodDays: 30 }) @@ -206,18 +207,18 @@ describe('LocalSpillStore service', () => { }) it('routes a sweep filesystem failure to ctx.logger.warn (service warn wiring)', async () => { - // A `session-*` entry that is a FILE, not a directory, makes readdir throw - // ENOTDIR inside the real sweep. The service's warn closure must forward it - // to ctx.logger.warn, and disposal must still settle cleanly. - const stray = join(root, 'session-stray'); writeFileSync(stray, 'x') + // A root that is a FILE, not a directory, makes readdir throw ENOTDIR inside + // the real sweep. The service's warn closure must forward it to + // ctx.logger.warn, and disposal must still settle cleanly. + const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x') const ctx = new Context() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) class Discovering extends LocalSpillStore { - protected override async gatherRoots(): Promise { return [this.root] } + protected override async gatherRoots(): Promise { return [{ path: this.root, pruneWhenEmpty: false }] } } - const fiber = await ctx.plugin(Discovering, { root, cleanupPeriodDays: 30 }) + const fiber = await ctx.plugin(Discovering, { root: filePath, cleanupPeriodDays: 30 }) await fiber.dispose() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root')) }) }) @@ -227,15 +228,16 @@ describe('LocalSpillStore service', () => { * gather open so a test can prove disposal awaits the sweep. */ class SweptStore extends LocalSpillStore { - static sweepRoots: string[] = [] + static sweepRoots: SweepRoot[] = [] static barrier: Promise | undefined - protected override async gatherRoots(): Promise { + protected override async gatherRoots(): Promise { if (SweptStore.barrier) await SweptStore.barrier return SweptStore.sweepRoots } } -async function runSweep(roots: string[], cleanupPeriodDays = 30): Promise { +/** Sweep the given roots via the fiber-owned startup sweep; `root` is the active (non-pruned) root. */ +async function runSweep(roots: SweepRoot[], cleanupPeriodDays = 30): Promise { SweptStore.sweepRoots = roots SweptStore.barrier = undefined const ctx = new Context() @@ -244,13 +246,18 @@ async function runSweep(roots: string[], cleanupPeriodDays = 30): Promise await fiber.dispose() } +/** The active configured root as a non-pruned sweep target (the common single-root case). */ +function active(path: string): SweepRoot { + return { path, pruneWhenEmpty: false } +} + describe('startup cleanup sweep', () => { it('deletes files older than the cutoff and keeps fresh ones', async () => { const dir = sessionDir(root, 'sess-1') mkdirSync(dir, { recursive: true }) const old = join(dir, 'old.txt'); writeAged(old, 'x', 40) const fresh = join(dir, 'fresh.txt'); writeAged(fresh, 'y', 1) - await runSweep([root]) + await runSweep([active(root)]) expect(existsSync(old)).toBe(false) expect(existsSync(fresh)).toBe(true) }) @@ -261,7 +268,7 @@ describe('startup cleanup sweep', () => { // mtime == cutoff: mtimeMs >= cutoffMs holds, so it is kept. Age it just // under 30d to avoid the sub-millisecond race of "exactly now - 30d". const boundary = join(dir, 'boundary.txt'); writeAged(boundary, 'x', 29.9) - await runSweep([root]) + await runSweep([active(root)]) expect(existsSync(boundary)).toBe(true) }) @@ -269,23 +276,23 @@ describe('startup cleanup sweep', () => { const dir = sessionDir(root, 'sess-1') mkdirSync(dir, { recursive: true }) const old = join(dir, 'old.txt'); writeAged(old, 'x', 400) - await runSweep([root], 0) + await runSweep([active(root)], 0) expect(existsSync(old)).toBe(true) }) - it('prunes a directory left empty, keeps one with a surviving file', async () => { + it('prunes a session directory left empty, keeps one with a surviving file', async () => { const emptied = sessionDir(root, 'emptied') const kept = sessionDir(root, 'kept') mkdirSync(emptied, { recursive: true }) mkdirSync(kept, { recursive: true }) writeAged(join(emptied, 'a.txt'), 'x', 40) writeAged(join(kept, 'fresh.txt'), 'y', 1) - await runSweep([root]) + await runSweep([active(root)]) expect(existsSync(emptied)).toBe(false) expect(existsSync(kept)).toBe(true) }) - it('skips symlinks and non-session entries', async () => { + it('skips a symlink INSIDE a session dir and non-session siblings', async () => { const dir = sessionDir(root, 'sess-1') mkdirSync(dir, { recursive: true }) // A symlink pointing at an old target must NOT be followed or deleted. @@ -294,7 +301,7 @@ describe('startup cleanup sweep', () => { // A non-session sibling directory under a shared root is untouched. const unrelated = join(root, 'not-a-session'); mkdirSync(unrelated) const unrelatedOld = join(unrelated, 'old.txt'); writeAged(unrelatedOld, 'x', 40) - await runSweep([root]) + await runSweep([active(root)]) // The symlink itself survives (lstat sees a link, not a file), so its dir is // not empty and is not pruned; the link target survives too. expect(existsSync(link)).toBe(true) @@ -302,12 +309,73 @@ describe('startup cleanup sweep', () => { expect(existsSync(unrelatedOld)).toBe(true) }) + it('does NOT follow a symlinked session directory (no deletion in the target)', async () => { + // A `session-<12hex>`-NAMED symlink pointing at a directory of old files must + // never be descended: lstat on the entry sees a link, so the target's files + // are left intact and the link itself is not removed. + const victimDir = join(root, 'victim'); mkdirSync(victimDir, { recursive: true }) + const victimOld = join(victimDir, 'old.txt'); writeAged(victimOld, 'x', 40) + const linkName = `session-${'a'.repeat(12)}` + const link = join(root, linkName); symlinkSync(victimDir, link) + await runSweep([active(root)]) + expect(existsSync(victimOld)).toBe(true) + expect(existsSync(link)).toBe(true) + }) + + it('sweeps only exact session-<12hex> names, not lookalikes', async () => { + // `session-backup` and `session-<11hex>` match the old startsWith check but + // are NOT backend-generated names; their old files must survive. + const backup = join(root, 'session-backup'); mkdirSync(backup, { recursive: true }) + const backupOld = join(backup, 'old.txt'); writeAged(backupOld, 'x', 40) + const shortHex = join(root, `session-${'a'.repeat(11)}`); mkdirSync(shortHex, { recursive: true }) + const shortOld = join(shortHex, 'old.txt'); writeAged(shortOld, 'x', 40) + // A real session dir alongside them IS swept, proving the sweep still runs. + const real = sessionDir(root, 'sess-1'); mkdirSync(real, { recursive: true }) + const realOld = join(real, 'old.txt'); writeAged(realOld, 'x', 40) + await runSweep([active(root)]) + expect(existsSync(backupOld)).toBe(true) + expect(existsSync(shortOld)).toBe(true) + expect(existsSync(realOld)).toBe(false) + }) + + it('prunes an emptied DISCOVERED default root but never the active root', async () => { + // A discovered prior-default root (pruneWhenEmpty) whose only session dir is + // emptied should have its outer directory removed too; the active root, even + // when fully emptied, must survive (the live process still writes into it). + const prior = mkdtempSync(join(tmpdir(), 'dsh-spill-')) + const priorDir = sessionDir(prior, 'old-sess'); mkdirSync(priorDir, { recursive: true }) + writeAged(join(priorDir, 'old.txt'), 'x', 40) + const activeDir = sessionDir(root, 'sess-1'); mkdirSync(activeDir, { recursive: true }) + writeAged(join(activeDir, 'old.txt'), 'x', 40) + try { + await runSweep([{ path: prior, pruneWhenEmpty: true }, active(root)]) + expect(existsSync(prior)).toBe(false) // discovered root pruned + expect(existsSync(root)).toBe(true) // active root kept + expect(existsSync(activeDir)).toBe(false) // its emptied session dir still pruned + } finally { + rmSync(prior, { recursive: true, force: true }) + } + }) + + it('does NOT prune a discovered root that still holds a fresh file', async () => { + const prior = mkdtempSync(join(tmpdir(), 'dsh-spill-')) + const priorDir = sessionDir(prior, 'sess'); mkdirSync(priorDir, { recursive: true }) + writeAged(join(priorDir, 'fresh.txt'), 'y', 1) + try { + await runSweep([{ path: prior, pruneWhenEmpty: true }]) + expect(existsSync(prior)).toBe(true) + expect(existsSync(priorDir)).toBe(true) + } finally { + rmSync(prior, { recursive: true, force: true }) + } + }) + it('covers the configured root AND discovered default roots (real gatherRoots)', async () => { // A prior default root under an isolated fake tmpdir + the configured root. // This test drives the REAL gatherRoots/discoverDefaultRoots path by seaming // only the tmpdir scan base, not gatherRoots itself. const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-')) - const priorDefault = join(fakeTmp, `${DEFAULT_ROOT_PREFIX}ABCDEF`) + const priorDefault = mkdtempSync(join(fakeTmp, DEFAULT_ROOT_PREFIX)) const priorDir = sessionDir(priorDefault, 'old-sess') mkdirSync(priorDir, { recursive: true }) const priorOld = join(priorDir, 'old.txt'); writeAged(priorOld, 'x', 40) @@ -323,6 +391,9 @@ describe('startup cleanup sweep', () => { await fiber.dispose() expect(existsSync(priorOld)).toBe(false) expect(existsSync(cfgOld)).toBe(false) + // The discovered prior-default root is pruned; the configured root is kept. + expect(existsSync(priorDefault)).toBe(false) + expect(existsSync(root)).toBe(true) } finally { rmSync(fakeTmp, { recursive: true, force: true }) } @@ -330,10 +401,11 @@ describe('startup cleanup sweep', () => { it('de-dups when the active root is itself a discovered default (real gatherRoots)', async () => { // The configured root lives directly under the seamed base and matches the - // default prefix, so discovery finds it AND it is the active root — the sweep - // must run once, not choke on the duplicate. + // default shape, so discovery finds it AND it is the active root — the sweep + // must run once, not choke on the duplicate, and must NOT prune the active + // root even though discovery would otherwise mark a default root prunable. const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-')) - const activeDefault = join(fakeTmp, `${DEFAULT_ROOT_PREFIX}ACTIVE`) + const activeDefault = mkdtempSync(join(fakeTmp, DEFAULT_ROOT_PREFIX)) const dir = sessionDir(activeDefault, 'sess-1') mkdirSync(dir, { recursive: true }) const old = join(dir, 'old.txt'); writeAged(old, 'x', 40) @@ -345,6 +417,8 @@ describe('startup cleanup sweep', () => { const fiber = await ctx.plugin(Discovering, { root: activeDefault, cleanupPeriodDays: 30 }) await fiber.dispose() expect(existsSync(old)).toBe(false) + // Active root survives even though its name matches the discovered shape. + expect(existsSync(activeDefault)).toBe(true) } finally { rmSync(fakeTmp, { recursive: true, force: true }) } @@ -357,7 +431,7 @@ describe('startup cleanup sweep', () => { // Hold the sweep open behind a barrier we control. let release!: () => void - SweptStore.sweepRoots = [root] + SweptStore.sweepRoots = [active(root)] SweptStore.barrier = new Promise((resolve) => { release = resolve }) const ctx = new Context() @@ -380,35 +454,28 @@ describe('startup cleanup sweep', () => { // A path that is a FILE, not a directory: readdir(root) throws ENOTDIR. The // sweep must log and return, never reject. const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x') - await expect(sweepSpillRoots({ roots: [filePath], cutoffMs: Date.now(), warn })).resolves.toBeUndefined() + await expect(sweepSpillRoots({ roots: [active(filePath)], cutoffMs: Date.now(), warn })).resolves.toBeUndefined() expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root')) }) it('a nonexistent root is silent (the common no-spill-yet case)', async () => { const warn = vi.fn() - await sweepSpillRoots({ roots: [join(root, 'never-created')], cutoffMs: Date.now(), warn }) + await sweepSpillRoots({ roots: [active(join(root, 'never-created'))], cutoffMs: Date.now(), warn }) expect(warn).not.toHaveBeenCalled() }) - - it('a session entry that is a file (not a dir) is reported, not pruned', async () => { - const warn = vi.fn() - // `session-strayfile` matches the session- prefix but is a regular file, so - // readdir on it throws ENOTDIR: reported, left in place (not empty → no prune). - const stray = join(root, 'session-strayfile'); writeFileSync(stray, 'x') - await sweepSpillRoots({ roots: [root], cutoffMs: Date.now(), warn }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read')) - expect(existsSync(stray)).toBe(true) - }) }) describe('discoverDefaultRoots', () => { it('returns only real dsh-spill-* directories, excluding symlinks and non-matches', async () => { const base = mkdtempSync(join(tmpdir(), 'dsh-disc-')) try { - const realRoot = join(base, `${DEFAULT_ROOT_PREFIX}real`); mkdirSync(realRoot) + // A real backend-shaped root (dsh-spill-<6>) via mkdtemp — the only match. + const realRoot = mkdtempSync(join(base, DEFAULT_ROOT_PREFIX)) mkdirSync(join(base, 'unrelated-dir')) - writeFileSync(join(base, `${DEFAULT_ROOT_PREFIX}file`), 'x') // matches prefix but is a file - symlinkSync(realRoot, join(base, `${DEFAULT_ROOT_PREFIX}link`)) // matches prefix but is a symlink + // Names of the EXACT default shape that must still be excluded because they + // are not real directories the backend could have created. + writeFileSync(join(base, `${DEFAULT_ROOT_PREFIX}file01`), 'x') // matches shape but is a file + symlinkSync(realRoot, join(base, `${DEFAULT_ROOT_PREFIX}link01`)) // matches shape but is a symlink const found = await discoverDefaultRoots(() => {}, base) expect(found).toEqual([realRoot]) } finally { From dbb3bcca8e0365874bda21566dd4233a87bba5f1 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 20 Jul 2026 14:55:21 +0800 Subject: [PATCH 047/138] test(spill-local): v8-ignore the two race-only sweep catch branches The exact-shape fix added two filesystem-failure catch branches that only fire on a race/permission fault the caller already guards against (the session-dir readdir after an isDirectory() check, and the discovered-root rmdir after the root was observed empty). Neither is deterministically reproducible in-process, so tag both with the same reasoned v8 ignore the sibling catch blocks already use, restoring per-file 100% coverage and the symmetry between the parallel rmdir handlers. --- packages/spill/spill-local/src/store.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index 01528fd573..0303a8f639 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -225,11 +225,13 @@ async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Pro try { names = await readdir(dir) } catch (error: unknown) { - // A `session-*` entry that is not a readable directory (a stray file, or an - // unreadable/vanished dir) is not ours to fix — report and leave it. False - // keeps it out of the prune step. + /* v8 ignore start -- the caller lstat'd this entry and confirmed a real + directory just before the call, so readdir fails only when the dir races + away (ENOENT) or a permission/IO fault strikes in that window; not + deterministically reproducible. False keeps it out of the prune step. */ warn(`spill-local: failed to read ${dir}: ${String(error)}`) return false + /* v8 ignore stop */ } let remaining = names.length for (const name of names) { @@ -328,12 +330,15 @@ export async function sweepSpillRoots(options: SweepOptions): Promise { try { await rmdir(root.path) } catch (error: unknown) { - // A concurrent process may have written a fresh spill into this root - // after our scan (ENOTEMPTY), or removed it already (ENOENT) — benign - // races. Anything else is reported. + /* v8 ignore start -- prune runs only on a root whose every child was + reclaimed; a failure here means a concurrent writer added a fresh + spill after our scan (ENOTEMPTY) or removed the root already (ENOENT) + or a permission/IO fault struck — all races outside deterministic + in-process testing. */ if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { warn(`spill-local: failed to prune root ${root.path}: ${String(error)}`) } + /* v8 ignore stop */ } } } From 545d1779112d945a39ccda1dd833e337e3656c33 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 20 Jul 2026 15:55:17 +0800 Subject: [PATCH 048/138] fix(spill-local): make startup cleanup race-safe --- ...26-07-08-tool-output-spill-files.i18n.yaml | 4 +- .../2026-07-08-tool-output-spill-files.md | 2 +- .../2026-07-08-tool-output-spill-files.zh.md | 3 +- ...7-17-local-spill-startup-cleanup.i18n.yaml | 6 +- .../2026-07-17-local-spill-startup-cleanup.md | 6 +- ...26-07-17-local-spill-startup-cleanup.zh.md | 8 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 11 +- packages/spill/spill-local/README.i18n.yaml | 4 +- packages/spill/spill-local/README.md | 2 +- packages/spill/spill-local/README.zh.md | 11 +- packages/spill/spill-local/src/cleanup.ts | 276 ++++++++++++++ packages/spill/spill-local/src/index.ts | 18 +- packages/spill/spill-local/src/store.ts | 353 +++--------------- .../spill-local/tests/spill-local.spec.ts | 20 +- 16 files changed, 387 insertions(+), 343 deletions(-) create mode 100644 packages/spill/spill-local/src/cleanup.ts diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 5ba89a4412..20f4e54eae 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md -2026-07-08-tool-output-spill-files.md: 14667b74ca877622d05196e9bf83945a842fe366 -2026-07-08-tool-output-spill-files.zh.md: db297fa6bee707a1d5a10d20260ce6b8a660d207 +2026-07-08-tool-output-spill-files.md: 81a292a00af63b0aa9145a0c556a428ab8c49d64 +2026-07-08-tool-output-spill-files.zh.md: 8d9e60d461297fb11ff2252e91f98a0cfad33f62 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index 6164066dbb..81a292a00a 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -161,7 +161,7 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa - Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient. - Remote or database storage backends for ACP or remote environments where a local path is not meaningful. -Cleanup shipped for the local backend as a one-shot startup sweep, not tied to session deletion — see the [startup-cleanup RFC](./2026-07-17-local-spill-startup-cleanup.md). The seam still defines no per-session cleanup policy; retention is a backend concern. +Cleanup shipped for the local backend as a one-shot startup sweep, not tied to session deletion — see the [startup-cleanup Agent Note](./2026-07-17-local-spill-startup-cleanup.md). The seam still defines no per-session cleanup policy; retention is a backend concern. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index db297fa6be..8d9e60d461 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -160,7 +160,8 @@ ctx.tools.register(defineTool({ - 由工具负责的 subagent 执行轨迹 spill(`await run.result`,在 `run.dispose()` 前读取进程内子会话,保存 JSONL)。 - 如果内置的 `read` 跳过规则不足,再增加逐工具选择退出或逐工具策略声明。 - 面向 ACP(Agent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。 -- 旧 spill 文件的清理和保留策略,很可能与会话清理绑定。 + +本地后端通过一次性启动扫描清理旧文件,而不是绑定到会话删除——参见[启动清理 Agent Note](./2026-07-17-local-spill-startup-cleanup.zh.md)。seam 仍未定义逐会话清理策略;保留策略属于后端。 ## 测试 diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml index 97d2b5426b..511dda5a74 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-17-local-spill-startup-cleanup.md: ca4931776f89e641f127072f665e238ca2a1600d -2026-07-17-local-spill-startup-cleanup.zh.md: b90923844ab71e1ce570e8adb66f81aed7bc3488 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md +2026-07-17-local-spill-startup-cleanup.md: 96378d6ea785d90385f517c1b9a01073ade47fa2 +2026-07-17-local-spill-startup-cleanup.zh.md: a154cfb824d3a747c2c9acc2b707eb14d703ce2e diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md index ca4931776f..96378d6ea7 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md @@ -12,9 +12,9 @@ The local spill backend never deleted the full tool results it wrote. Every over `dsh-spill-local` runs one best-effort cleanup sweep after activation. It does not delay service availability, is owned by the plugin fiber (a single `ctx.effect` whose generator launches the sweep and yields an async disposer that awaits it), and is awaited during disposal so no sweep I/O outlives the fiber. There is no recurring timer and no separate process. -A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. An invalid value (negative or fractional) throws at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir, deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`, and prunes directories left empty. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn` — the sweep never throws, so it cannot reject activation or a concurrent spill write. Discovery excludes symlinks and non-directories, returning only real `dsh-spill-*` directories the backend could have created. +A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. An invalid value (negative or fractional) throws at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir and deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`. It prunes empty session directories and roots only for discovered prior-default roots; the active root keeps its session directories so pruning cannot race a local write, while writes recreate a session directory if another process prunes a discovered root that is still active. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn`, and a warning-sink exception is also contained — the sweep never throws, so it cannot reject activation or a concurrent spill write. Discovery excludes symlinks and non-directories, returning only real `dsh-spill-*` directories the backend could have created. -The ctx-free mechanics live in `packages/spill/spill-local/src/store.ts` (`sweepSpillRoots`, `discoverDefaultRoots`, `DEFAULT_ROOT_PREFIX`, `isErrno`), unit-testable without a `ctx`; the service in `src/index.ts` owns the config, the cutoff, and the fiber-owned launch/await. +The ctx-free sweep mechanics live in `packages/spill/spill-local/src/cleanup.ts` (`sweepSpillRoots`, `discoverDefaultRoots`), unit-testable without a `ctx`; `store.ts` owns root naming, path derivation, and writes, while the service in `src/index.ts` owns the config, cutoff, and fiber-owned launch/await. ## Alternatives considered @@ -32,4 +32,4 @@ Cleanup cost the backend a startup sweep and a config knob, and bought a bounded ## Testing -`dsh-spill-local` unit tests cover the age boundary (strictly-older expires, boundary kept), `cleanupPeriodDays: 0` disabling, empty-directory pruning, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage through the real `gatherRoots`/`discoverDefaultRoots` path, active-root de-duplication, load-time validation of a bad `cleanupPeriodDays`, filesystem-failure containment (logged, not thrown) both directly and through the service's `ctx.logger.warn` wiring, and the quiescence contract — activation is available while a barrier-held sweep is parked, and disposal only settles after the sweep finishes. +`dsh-spill-local` unit tests cover the age boundary (strictly-older expires, boundary kept), `cleanupPeriodDays: 0` disabling, discovered-root pruning, active-directory preservation, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage through the real `gatherRoots`/`discoverDefaultRoots` path, active-root de-duplication, load-time validation of a bad `cleanupPeriodDays`, filesystem- and warning-sink-failure containment both directly and through the service's `ctx.logger.warn` wiring, and the quiescence contract — activation is available while a barrier-held sweep is parked, and disposal only settles after the sweep finishes. diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md index b90923844a..a154cfb824 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件,因此配置的根目录会无限增长,而每进程默认的 `dsh-spill-*` 根目录也会跨多次运行不断累积。立即删除是错误的,因为已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。[工具输出 spill 策略](./2026-07-08-tool-output-spill-files.md)需要一个有界的本地存储生命周期。 +本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件,因此配置的根目录会无限增长,而每进程默认的 `dsh-spill-*` 根目录也会跨多次运行不断累积。立即删除是错误的,因为已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。[工具输出 spill 策略](./2026-07-08-tool-output-spill-files.zh.md)需要一个有界的本地存储生命周期。 ## 决策 `dsh-spill-local` 在激活后运行一次尽力而为的清理扫描。它不延迟服务可用性,由插件 fiber 拥有(一个 `ctx.effect`,其生成器启动该扫描并让出一个等待它的异步 disposer),并在 dispose 期间被等待,因此没有扫描 I/O 会存活到 fiber 之后。既没有周期性定时器,也没有独立进程。 -`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。无效值(负数或小数)在加载时抛出。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件,并修剪清空后的目录。它使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。发现过程排除符号链接与非目录,只返回后端可能创建过的真实 `dsh-spill-*` 目录。 +`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。无效值(负数或小数)在加载时抛出。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,并删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件。它只修剪发现的先前默认根目录中的空会话目录和空根目录;活动根目录会保留其会话目录,避免修剪操作与本地写入竞争,而当其他进程修剪了一个仍在使用的发现根目录时,写入操作会重新创建会话目录。扫描使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录,警告接收方抛出的异常也会被兜底——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。发现过程排除符号链接与非目录,只返回后端可能创建过的真实 `dsh-spill-*` 目录。 -无 ctx 依赖的机制位于 `packages/spill/spill-local/src/store.ts`(`sweepSpillRoots`、`discoverDefaultRoots`、`DEFAULT_ROOT_PREFIX`、`isErrno`),无需 `ctx` 即可做单元测试;`src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。 +无 ctx 依赖的扫描机制位于 `packages/spill/spill-local/src/cleanup.ts`(`sweepSpillRoots`、`discoverDefaultRoots`),无需 `ctx` 即可做单元测试;`store.ts` 负责根目录命名、路径推导与写入,而 `src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。 ## 考虑过的替代方案 @@ -32,4 +32,4 @@ Status: implemented ## 验证 -`dsh-spill-local` 单元测试覆盖了年龄边界(严格更旧者过期,边界值保留)、`cleanupPeriodDays: 0` 的禁用、空目录修剪、符号链接/无关条目的跳过、通过真实 `gatherRoots`/`discoverDefaultRoots` 路径对配置根加发现根的覆盖、活动根去重、对错误 `cleanupPeriodDays` 的加载期校验、文件系统失败的兜底(记录而非抛出)——既直接测试,也经由服务的 `ctx.logger.warn` 接线测试——以及静止契约:在一个被屏障挂起的扫描停驻期间激活仍然可用,而 dispose 只有在扫描结束后才会完成。 +`dsh-spill-local` 单元测试覆盖了年龄边界(严格更旧者过期,边界值保留)、`cleanupPeriodDays: 0` 的禁用、发现根目录的修剪、活动目录的保留、符号链接/无关条目的跳过、通过真实 `gatherRoots`/`discoverDefaultRoots` 路径对配置根加发现根的覆盖、活动根去重、对错误 `cleanupPeriodDays` 的加载期校验、直接测试以及经由服务的 `ctx.logger.warn` 接线测试所覆盖的文件系统与警告接收方失败兜底,以及静止契约:在一个被屏障挂起的扫描停驻期间激活仍然可用,而 dispose 只有在扫描结束后才会完成。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index aed3574106..66d5592ba2 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: a845fe22e13ed085765668c7ec8d54d6bbdf129a -config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e +config-catalog.md: 9fc8c333510c568686ce43f4aa1f6d0ae6bc3615 +config-catalog.zh.md: 4fb689f63631740d485a854e10a12c6d92c6f4ac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ac7c00738c..9fc8c33351 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2074,7 +2074,7 @@ export interface Config { } ``` -Source: [`packages/spill/spill-local/src/index.ts:28`](../packages/spill/spill-local/src/index.ts) +Source: [`packages/spill/spill-local/src/index.ts:31`](../packages/spill/spill-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 39ba9d4836..4fb689f636 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2064,10 +2064,19 @@ export interface Config { * a local deployment. Set it to keep spill files under a known location. */ root?: string + /** + * Age in days after which a spill file is eligible for the one-shot startup + * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose + * `mtime` is strictly older than the cutoff are deleted and emptied + * directories are pruned; fresh files, symlinks, and unrelated entries are + * left untouched. Retention is deliberate — a resumed or forked session may + * still reference an older locator until it ages out. + */ + cleanupPeriodDays?: number } ``` -来源:[`packages/spill/spill-local/src/index.ts:22`](../packages/spill/spill-local/src/index.ts) +来源:[`packages/spill/spill-local/src/index.ts:31`](../packages/spill/spill-local/src/index.ts) diff --git a/packages/spill/spill-local/README.i18n.yaml b/packages/spill/spill-local/README.i18n.yaml index 37659c3aac..dd414de9b9 100644 --- a/packages/spill/spill-local/README.i18n.yaml +++ b/packages/spill/spill-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/spill/spill-local/README.md -README.md: 2270a65d9270e1549a9e48d6a36b821e48c29070 -README.zh.md: b3e4999d8f2d982ef01637199299f79d06e12c4b +README.md: 75bde20c423e1d2aa4bba49201b5bb0369d34fd0 +README.zh.md: 0539969cc4bd4da8dad4f0ac00436476555fd08c diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md index c97ddf89cd..75bde20c42 100644 --- a/packages/spill/spill-local/README.md +++ b/packages/spill/spill-local/README.md @@ -23,7 +23,7 @@ Files land at `/session-/​-`: The backend never deletes a spill on the write path — a persisted, resumed, or forked session may still reference an older locator, so immediate deletion would break retrieval. Instead, one best-effort sweep runs **once after activation**: it does not delay service availability, is owned by the plugin fiber, and is awaited on disposal (no sweep I/O outlives the fiber). There is no recurring timer and no separate process, so a long-lived deployment is not swept again until its next restart. -The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. Within each, it deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays` and prunes any directory left empty. It never follows or deletes a symlink, skips unrelated entries, and contains every filesystem failure (logged, never thrown) so it cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff. +The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. Within each, it deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`; it prunes empty session directories and roots only for discovered prior-default roots, while the active root keeps its session directories to avoid racing a write. A write recreates its session directory if another process prunes a discovered root that is still active. The sweep never follows or deletes a symlink, skips unrelated entries, and contains every filesystem or warning-sink failure so it cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff. `saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design, and the [startup-cleanup Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md) for the sweep. diff --git a/packages/spill/spill-local/README.zh.md b/packages/spill/spill-local/README.zh.md index b3e4999d8f..0539969cc4 100644 --- a/packages/spill/spill-local/README.zh.md +++ b/packages/spill/spill-local/README.zh.md @@ -17,8 +17,15 @@ | 键 | 默认值 | 含义 | |---|---|---| | `root` | 私有 0700 临时目录 | spill 文件的根目录。设置后可将这些文件保存在已知位置。 | +| `cleanupPeriodDays` | `30` | spill 文件在一次性启动清理扫描中符合删除条件前需经过的天数。`0` 禁用清理。 | -`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)。 +## 启动清理 + +后端不会在写入路径上删除 spill,因为已持久化、已恢复或 fork 后的会话仍可能引用较旧的定位信息,立即删除会使其无法取回。后端会改为在激活后**仅运行一次**尽力而为的扫描:扫描不延迟服务可用性,由插件 fiber 拥有,并在 dispose 期间被等待(不会有扫描 I/O 存活至 fiber 之后)。它既不使用周期性定时器,也不运行独立进程,因此长期运行的部署要到下次重启才会再次扫描。 + +扫描会检查配置的 `root` **以及**先前使用默认根目录的运行在操作系统临时目录下留下的所有 `dsh-spill-*` 临时根目录。在每个根目录中,扫描会删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件;它只修剪发现的先前默认根目录中的空会话目录和空根目录,而活动根目录会保留其会话目录,以避免与写入操作竞争。如果另一个进程修剪了一个仍在使用的发现根目录,写入操作会重新创建其会话目录。扫描绝不会跟随或删除符号链接,会跳过无关条目,并兜底每一次文件系统或警告接收方失败,因此无法使激活或并发 spill 写入失败。保留是刻意的:旧的模型可见定位信息只有超过截止时间后才会失效。 + +`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md),扫描机制见[启动清理 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md)。 ## 模型体验 @@ -30,5 +37,5 @@ ## 已知限制与暂缓事项 -- **本地 spill 文件会持续存在,直到外部清理为止**:该后端不提供会话生命周期删除或按时间保留的策略,因为已持久化、已恢复和 fork 后的会话可能仍在引用某个路径。 +- **长期运行的部署需等到重启才会被扫描**:一次性扫描仅在激活后运行一次,因此运行期间达到 `cleanupPeriodDays` 的文件要到下次启动才会被回收;没有周期性定时器。 - **定位信息需要与其位于同一文件系统的消费方**:远程或虚拟部署需要另一个 `SpillStore` 后端,其定位信息和取回指引在该环境中有明确含义。 diff --git a/packages/spill/spill-local/src/cleanup.ts b/packages/spill/spill-local/src/cleanup.ts new file mode 100644 index 0000000000..c138f68a84 --- /dev/null +++ b/packages/spill/spill-local/src/cleanup.ts @@ -0,0 +1,276 @@ +/** Startup cleanup mechanics for local spill roots. */ +import { lstat, readdir, rmdir, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { DEFAULT_ROOT_PREFIX, isErrno } from './store.ts' + +/** + * A backend-generated default root name: `dsh-spill-` plus the 6-character + * suffix `mkdtemp` appends. Discovery matches this + * EXACT shape, not the bare prefix, so an unrelated `dsh-spill-test-*` fixture + * or a foreign tool's differently-shaped `dsh-spill-…` directory is never + * mistaken for a backend root to sweep. + */ +const DEFAULT_ROOT_RE = new RegExp(`^${DEFAULT_ROOT_PREFIX}[A-Za-z0-9]{6}$`) + +/** + * A backend-generated session directory name: `session-` plus the 12 lowercase + * hex characters {@link sessionDir} derives from `sha256(sessionId)`. The sweep + * only descends into entries of this EXACT shape, so an unrelated + * `session-backup` directory under a shared configured root is never swept. + */ +const SESSION_DIR_RE = /^session-[0-9a-f]{12}$/ + +/** A one-argument warning sink — the sweep's only side effect on failure (never throws). */ +export type WarnFn = (message: string) => void + +/** Report a best-effort sweep failure without allowing the warning sink to reject cleanup. */ +function warnSafely(warn: WarnFn, message: string): void { + try { + warn(message) + } catch { + // Warning sinks are observational callbacks; cleanup must remain best-effort + // even when a logger implementation throws. + } +} + +/** One root to sweep, plus whether its empty session directories and root may be pruned. */ +export interface SweepRoot { + /** Absolute spill root to sweep. */ + path: string + /** + * When `true`, prune empty `session-*` children and then remove the root once + * empty. Set for DISCOVERED prior-default `dsh-spill-*` roots (one per past + * process — otherwise they accumulate empty forever), never for the + * active/configured root the live process is still writing into. Writes retry + * if another process still using a discovered root races its pruning. + */ + pruneWhenEmpty: boolean +} + +/** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */ +export interface SweepOptions { + /** Roots to sweep (configured/active root and/or discovered prior-default roots). */ + roots: SweepRoot[] + /** + * Epoch-millis cutoff: a regular file is deleted when its `mtime` is strictly + * older than this. The caller derives it from `now - cleanupPeriodDays`, so a + * file written exactly at the boundary is kept (only strictly-older expires). + */ + cutoffMs: number + /** Where a contained filesystem failure is reported; the sweep itself never throws. */ + warn: WarnFn +} + +/** + * Delete a single path, treating a concurrent-race disappearance as success. + * A parallel process (or another sweep) may `unlink` the same file between our + * scan and our own `unlink` — ENOENT then means the goal (file gone) already + * holds, so it is not a failure. Any other error is reported and swallowed. + * + * @param path The absolute file path to remove. + * @param warn Sink for a non-ENOENT failure message. + * @returns Resolves once the removal was attempted (never rejects). + */ +async function unlinkIdempotent(path: string, warn: WarnFn): Promise { + try { + await unlink(path) + } catch (error: unknown) { + /* v8 ignore start -- reached only when a file selected for deletion (a + regular file that passed lstat) then fails to unlink: either it raced away + (ENOENT) or a permission/IO fault struck between the stat and the unlink. + Neither is deterministically reproducible in-process. */ + if (isErrno(error, 'ENOENT')) return + warnSafely(warn, `spill-local: failed to delete ${path}: ${String(error)}`) + /* v8 ignore stop */ + } +} + +/** + * Sweep one spill session directory: delete expired regular files, skip + * everything else, and report the directory empty afterward so the caller can + * prune it. The `dir` entry MUST be a real directory — the caller `lstat`s it + * first and skips a symlink, so this never follows a `session-*` symlink into a + * foreign tree. Inside, a symlink or any non-regular entry (socket, fifo, nested + * dir) is left untouched — `lstat` never follows a link, so a planted symlink + * can neither be deleted nor redirect the age check. Every per-entry failure is + * contained: one unreadable file does not abort the directory. + * + * @param dir The absolute session directory to scan (already confirmed a real dir). + * @param cutoffMs Files with `mtime` strictly older than this are deleted. + * @param warn Sink for contained filesystem failures. + * @returns `true` when the directory holds no entries after the sweep (a prune candidate). + */ +async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Promise { + let names: string[] + try { + names = await readdir(dir) + } catch (error: unknown) { + /* v8 ignore start -- the caller lstat'd this entry and confirmed a real + directory just before the call, so readdir fails only when the dir races + away (ENOENT) or a permission/IO fault strikes in that window; not + deterministically reproducible. False keeps it out of the prune step. */ + warnSafely(warn, `spill-local: failed to read ${dir}: ${String(error)}`) + return false + /* v8 ignore stop */ + } + let remaining = names.length + for (const name of names) { + const path = join(dir, name) + let stats + try { + stats = await lstat(path) + } catch (error: unknown) { + /* v8 ignore start -- an entry that readdir just returned then fails to + lstat only by racing away (ENOENT) or a permission/IO fault; keep it out + of the deterministic test surface. */ + if (isErrno(error, 'ENOENT')) { remaining--; continue } + warnSafely(warn, `spill-local: failed to stat ${path}: ${String(error)}`) + continue + /* v8 ignore stop */ + } + // Only regular files expire. Symlinks and other special entries are skipped + // (never followed) so the sweep cannot be redirected or delete a link. + if (!stats.isFile()) continue + if (stats.mtimeMs >= cutoffMs) continue + await unlinkIdempotent(path, warn) + remaining-- + } + return remaining === 0 +} + +/** + * Best-effort one-shot cleanup: across each root, delete expired regular files + * under its `session-*` directories, pruning empty directories only in + * discovered prior-default roots. The active root keeps its session directories + * to avoid racing a local write; writes recreate a directory pruned by another + * process. Every filesystem and warning-sink failure is contained, so a caller + * can await this during activation/disposal without it ever rejecting. + * + * @param options The roots to sweep, the age cutoff, and the failure sink. + * @returns Resolves when the sweep finishes (never rejects). + */ +export async function sweepSpillRoots(options: SweepOptions): Promise { + const { roots, cutoffMs, warn } = options + for (const root of roots) { + let entries: string[] + try { + entries = await readdir(root.path) + } catch (error: unknown) { + // A root that does not exist yet (no spill ever written) is the common + // case, not an error: ENOENT is silent, anything else is reported. + if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to read root ${root.path}: ${String(error)}`) + continue + } + // Track whether the root holds ANY entry the sweep did not fully reclaim, so + // a discovered prior-default root can be pruned only when nothing remains. + let rootEmptiable = true + for (const name of entries) { + // Only the backend's own `session-<12 hex>` directories are swept; an + // unrelated sibling (`session-backup`, a stray file) is left untouched and + // blocks pruning the root. + if (!SESSION_DIR_RE.test(name)) { rootEmptiable = false; continue } + const dir = join(root.path, name) + let stats + try { + // lstat the session entry itself: a `session-*` SYMLINK must never be + // followed (readdir/unlink through it would delete files in a foreign + // target). Only a real directory is swept. + stats = await lstat(dir) + } catch (error: unknown) { + /* v8 ignore start -- an entry readdir just returned fails to lstat only + by racing away (ENOENT) or a permission/IO fault; not deterministically + reproducible. */ + if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to stat ${dir}: ${String(error)}`) + continue + /* v8 ignore stop */ + } + if (!stats.isDirectory()) { rootEmptiable = false; continue } + const empty = await sweepSessionDir(dir, cutoffMs, warn) + if (!empty) { rootEmptiable = false; continue } + if (!root.pruneWhenEmpty) { + // The active root remains writable while cleanup runs. Leaving its empty + // session directories in place closes the mkdir/rmdir race with saveText. + rootEmptiable = false + continue + } + try { + await rmdir(dir) + } catch (error: unknown) { + /* v8 ignore start -- prune runs only on a dir observed empty; a failure + here means a concurrent writer added a file (ENOTEMPTY) or a + permission/IO fault struck — both are races outside deterministic + in-process testing. */ + rootEmptiable = false + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { + warnSafely(warn, `spill-local: failed to prune ${dir}: ${String(error)}`) + } + /* v8 ignore stop */ + } + } + // A discovered prior-default root (one per past process) is removed once its + // last session dir is gone — otherwise empty roots accumulate forever and + // every future startup rescans them. The active/configured root is never + // pruned (the live process is still writing into it). + if (root.pruneWhenEmpty && rootEmptiable) { + try { + await rmdir(root.path) + } catch (error: unknown) { + /* v8 ignore start -- prune runs only on a root whose every child was + reclaimed; a failure here means a concurrent writer added a fresh + spill after our scan (ENOTEMPTY) or removed the root already (ENOENT) + or a permission/IO fault struck — all races outside deterministic + in-process testing. */ + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { + warnSafely(warn, `spill-local: failed to prune root ${root.path}: ${String(error)}`) + } + /* v8 ignore stop */ + } + } + } +} + +/** + * Discover prior default spill roots: the `dsh-spill-<6 chars>` directories + * directly under `base` (the OS tmpdir) that earlier default-root runs created. + * A long-lived deployment + * with a configured root will find none; a series of default-root runs + * accumulates one per process, so the startup sweep reclaims them all. Matching + * is the EXACT `mkdtemp` shape (see {@link DEFAULT_ROOT_RE}), not the bare + * prefix, so an unrelated `dsh-spill-test-*` fixture or a foreign + * differently-shaped directory is never swept; symlinks and non-directories are + * excluded too — only real directories the backend could have created. + * + * @param warn Sink for a failure reading `base` (returns `[]` on failure). + * @param base The directory to scan; defaults to the OS tmpdir (a test seam). + * @returns Absolute paths of the discovered default roots (possibly empty). + */ +export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise { + let entries: string[] + try { + entries = await readdir(base) + } catch (error: unknown) { + warnSafely(warn, `spill-local: failed to scan ${base} for default roots: ${String(error)}`) + return [] + } + const roots: string[] = [] + for (const name of entries) { + if (!DEFAULT_ROOT_RE.test(name)) continue + const path = join(base, name) + let stats + try { + // lstat, not stat: a symlink named `dsh-spill-*` must not be treated as a + // root we then sweep (it could point anywhere). + stats = await lstat(path) + } catch (error: unknown) { + /* v8 ignore start -- an entry readdir just returned fails to lstat only by + racing away (ENOENT) or a permission/IO fault; not deterministically + reproducible. */ + if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to stat default root ${path}: ${String(error)}`) + continue + /* v8 ignore stop */ + } + if (stats.isDirectory()) roots.push(path) + } + return roots +} diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts index 7c4069c539..f767a1a12d 100644 --- a/packages/spill/spill-local/src/index.ts +++ b/packages/spill/spill-local/src/index.ts @@ -15,11 +15,14 @@ import { tmpdir } from 'node:os' import z from '@deepseek-ai/schemastery' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' -import { discoverDefaultRoots, privateRoot, saveTextFile, sweepSpillRoots } from './store.ts' -import type { SweepRoot, WarnFn } from './store.ts' +import { discoverDefaultRoots, sweepSpillRoots } from './cleanup.ts' +import type { SweepRoot, WarnFn } from './cleanup.ts' +import { privateRoot, saveTextFile } from './store.ts' -export { discoverDefaultRoots, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir, sweepSpillRoots, DEFAULT_ROOT_PREFIX } from './store.ts' -export type { SavedText, SaveTextOptions, SweepOptions, SweepRoot, WarnFn } from './store.ts' +export { discoverDefaultRoots, sweepSpillRoots } from './cleanup.ts' +export type { SweepOptions, SweepRoot, WarnFn } from './cleanup.ts' +export { DEFAULT_ROOT_PREFIX, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir } from './store.ts' +export type { SavedText, SaveTextOptions } from './store.ts' /** Milliseconds in one day — converts the `cleanupPeriodDays` config to the sweep cutoff. */ const MS_PER_DAY = 24 * 60 * 60 * 1000 @@ -117,9 +120,10 @@ export class LocalSpillStore extends SpillStore { /** * The roots the startup sweep covers: each discovered prior-default * `dsh-spill-*` temp root (see {@link discoverDefaultRoots}), pruned when - * emptied, plus the active/configured root, which is swept but NEVER pruned - * (the live process is still writing into it). The active root is de-duped out - * of the discovered set so it is not swept twice or marked prunable. A test + * emptied, plus the active/configured root, whose root and session directories + * are NEVER pruned (the live process is still writing into them). The active + * root is de-duped out of the discovered set so it is not swept twice or + * marked prunable. A test * overrides this to inject an isolated root set — and, being the sweep's one * async gather point, to hold the sweep open across a disposal for the * quiescence check; it is a test seam, not a deployment knob. diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts index 0303a8f639..ba518cf035 100644 --- a/packages/spill/spill-local/src/store.ts +++ b/packages/spill/spill-local/src/store.ts @@ -8,44 +8,30 @@ import { createHash, randomBytes } from 'node:crypto' import { mkdtempSync } from 'node:fs' -import { lstat, mkdir, open, readdir, rmdir, unlink } from 'node:fs/promises' +import { mkdir, open } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -/** - * Filename prefix for the lazily-created private default spill roots - * (`mkdtemp(tmpdir()/dsh-spill-)`). Startup cleanup rediscovers these - * per-process roots (from prior runs that used no configured `root`) by this - * prefix — see {@link discoverDefaultRoots}. - */ +/** Prefix shared by default-root creation and startup discovery. */ export const DEFAULT_ROOT_PREFIX = 'dsh-spill-' /** - * A backend-generated default root name: `dsh-spill-` plus the 6-character - * suffix `mkdtemp` appends (see {@link privateRoot}). Discovery matches this - * EXACT shape, not the bare prefix, so an unrelated `dsh-spill-test-*` fixture - * or a foreign tool's differently-shaped `dsh-spill-…` directory is never - * mistaken for a backend root to sweep. + * Test a caught value for a Node system error code. + * + * @param error The caught value. + * @param code The expected system error code. + * @returns Whether the code matches. */ -const DEFAULT_ROOT_RE = /^dsh-spill-[A-Za-z0-9]{6}$/ - -/** - * A backend-generated session directory name: `session-` plus the 12 lowercase - * hex characters {@link sessionDir} derives from `sha256(sessionId)`. The sweep - * only descends into entries of this EXACT shape, so an unrelated - * `session-backup` directory under a shared configured root is never swept. - */ -const SESSION_DIR_RE = /^session-[0-9a-f]{12}$/ +export function isErrno(error: unknown, code: string): boolean { + return error instanceof Error && (error as NodeJS.ErrnoException).code === code +} let defaultRoot: string | undefined /** - * The default spill root: a private (0700) per-process directory under the OS - * tmpdir, created lazily. Predictable world-readable paths would let other - * local users read spilled tool output or pre-create symlinks; `mkdtemp` gives - * an unpredictable suffix and 0700 semantics. + * Return the lazily-created private per-process spill root. * - * @returns The lazily-created private spill root. + * @returns The private root path. */ export function privateRoot(): string { defaultRoot ??= mkdtempSync(join(tmpdir(), DEFAULT_ROOT_PREFIX)) @@ -63,8 +49,8 @@ export function privateRoot(): string { * inputs never collide. The whole-segment tokens `.`/`..` are escaped so they * can never traverse. An empty string encodes to `~` (never an empty segment). * - * @param raw The untrusted string to encode as one safe path segment. - * @returns An injective, filesystem-safe single path segment. + * @param raw Untrusted text. + * @returns One injective filesystem-safe path segment. */ export function encodeSegment(raw: string): string { if (raw.length === 0) return '~' @@ -74,317 +60,72 @@ export function encodeSegment(raw: string): string { for (let i = 0; i < raw.length; i++) { const code = raw.charCodeAt(i) const ch = String.fromCharCode(code) - if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) { - out += ch - } else { - out += '~' + code.toString(16).toUpperCase().padStart(4, '0') - } + out += ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch) + ? ch + : '~' + code.toString(16).toUpperCase().padStart(4, '0') } return out } /* jscpd:ignore-end */ /** - * The session-scoped directory: `/session-`, a short stable hash. + * Derive the stable session-scoped directory under a spill root. * - * @param root The spill root directory. - * @param sessionId The owning session id to hash into a stable directory name. - * @returns The absolute session-scoped spill directory path. + * @param root The spill root. + * @param sessionId The owning session id. + * @returns The stable session-scoped directory. */ export function sessionDir(root: string, sessionId: string): string { const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) return join(root, `session-${hash}`) } -/** Options for {@link saveTextFile} — the resolved root and the request fields the store needs. */ +/** Inputs needed to save a local spill file. */ export interface SaveTextOptions { - /** The spill root directory (configured or the lazy private default). */ + /** Spill root. */ root: string - /** The owning session id (scopes the directory). */ + /** Owning session id. */ sessionId: string - /** Caller-suggested base name; sanitized to one safe segment before use. */ + /** Caller-suggested filename. */ suggestedName: string - /** The full text to persist. */ + /** Full text to persist. */ content: string } /** A written spill file. */ export interface SavedText { + /** Absolute saved path. */ path: string + /** UTF-8 content length. */ bytes: number } /** - * Write `content` to a fresh file under the session-scoped directory and return - * its path + byte length. The filename is a random hex prefix plus the - * sanitized `suggestedName`, so it is unpredictable (defeats symlink planting in - * a shared root) AND stays readable. The open is exclusive + owner-only - * (`'wx', 0o600`): it fails on any existing path — symlink or not — so a - * pre-planted target cannot redirect the write. - * - * @param options The resolved root and request fields required to save the file. - * @returns The written file path and UTF-8 byte length. + * Write text to a fresh 0600 file below its private session directory. + * @param options The save request. + * @returns The saved path and UTF-8 byte length. */ export async function saveTextFile(options: SaveTextOptions): Promise { const dir = sessionDir(options.root, options.sessionId) - await mkdir(dir, { recursive: true, mode: 0o700 }) - const safeName = encodeSegment(options.suggestedName) - const path = join(dir, `${randomBytes(6).toString('hex')}-${safeName}`) - const bytes = Buffer.byteLength(options.content, 'utf8') - const handle = await open(path, 'wx', 0o600) + const path = join(dir, `${randomBytes(6).toString('hex')}-${encodeSegment(options.suggestedName)}`) + let handle + for (;;) { + await mkdir(dir, { recursive: true, mode: 0o700 }) + try { + handle = await open(path, 'wx', 0o600) + break + } catch (error: unknown) { + /* v8 ignore start -- requires another process to remove the directory + between mkdir and open, or an external permission/IO race. */ + if (isErrno(error, 'ENOENT')) continue + throw error + /* v8 ignore stop */ + } + } try { await handle.writeFile(options.content) } finally { await handle.close() } - return { path, bytes } -} - -/** A one-argument warning sink — the sweep's only side effect on failure (never throws). */ -export type WarnFn = (message: string) => void - -/** One root to sweep, plus whether an emptied root directory should itself be pruned. */ -export interface SweepRoot { - /** Absolute spill root to sweep. */ - path: string - /** - * When `true`, remove the root directory itself once its last `session-*` - * child is pruned. Set for DISCOVERED prior-default `dsh-spill-*` roots (one - * per past process — otherwise they accumulate empty forever), never for the - * active/configured root the live process is still writing into. - */ - pruneWhenEmpty: boolean -} - -/** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */ -export interface SweepOptions { - /** Roots to sweep (configured/active root and/or discovered prior-default roots). */ - roots: SweepRoot[] - /** - * Epoch-millis cutoff: a regular file is deleted when its `mtime` is strictly - * older than this. The caller derives it from `now - cleanupPeriodDays`, so a - * file written exactly at the boundary is kept (only strictly-older expires). - */ - cutoffMs: number - /** Where a contained filesystem failure is reported; the sweep itself never throws. */ - warn: WarnFn -} - -/** - * Delete a single path, treating a concurrent-race disappearance as success. - * A parallel process (or another sweep) may `unlink` the same file between our - * scan and our own `unlink` — ENOENT then means the goal (file gone) already - * holds, so it is not a failure. Any other error is reported and swallowed. - * - * @param path The absolute file path to remove. - * @param warn Sink for a non-ENOENT failure message. - * @returns Resolves once the removal was attempted (never rejects). - */ -async function unlinkIdempotent(path: string, warn: WarnFn): Promise { - try { - await unlink(path) - } catch (error: unknown) { - /* v8 ignore start -- reached only when a file selected for deletion (a - regular file that passed lstat) then fails to unlink: either it raced away - (ENOENT) or a permission/IO fault struck between the stat and the unlink. - Neither is deterministically reproducible in-process. */ - if (isErrno(error, 'ENOENT')) return - warn(`spill-local: failed to delete ${path}: ${String(error)}`) - /* v8 ignore stop */ - } -} - -/** - * True when `error` is a Node system error carrying the given `code`. - * - * @param error The caught value to test. - * @param code The `NodeJS.ErrnoException` code to match (e.g. `'ENOENT'`). - * @returns `true` when `error` is an `Error` whose `code` equals `code`. - */ -export function isErrno(error: unknown, code: string): boolean { - return error instanceof Error && (error as NodeJS.ErrnoException).code === code -} - -/** - * Sweep one spill session directory: delete expired regular files, skip - * everything else, and report the directory empty afterward so the caller can - * prune it. The `dir` entry MUST be a real directory — the caller `lstat`s it - * first and skips a symlink, so this never follows a `session-*` symlink into a - * foreign tree. Inside, a symlink or any non-regular entry (socket, fifo, nested - * dir) is left untouched — `lstat` never follows a link, so a planted symlink - * can neither be deleted nor redirect the age check. Every per-entry failure is - * contained: one unreadable file does not abort the directory. - * - * @param dir The absolute session directory to scan (already confirmed a real dir). - * @param cutoffMs Files with `mtime` strictly older than this are deleted. - * @param warn Sink for contained filesystem failures. - * @returns `true` when the directory holds no entries after the sweep (a prune candidate). - */ -async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Promise { - let names: string[] - try { - names = await readdir(dir) - } catch (error: unknown) { - /* v8 ignore start -- the caller lstat'd this entry and confirmed a real - directory just before the call, so readdir fails only when the dir races - away (ENOENT) or a permission/IO fault strikes in that window; not - deterministically reproducible. False keeps it out of the prune step. */ - warn(`spill-local: failed to read ${dir}: ${String(error)}`) - return false - /* v8 ignore stop */ - } - let remaining = names.length - for (const name of names) { - const path = join(dir, name) - let stats - try { - stats = await lstat(path) - } catch (error: unknown) { - /* v8 ignore start -- an entry that readdir just returned then fails to - lstat only by racing away (ENOENT) or a permission/IO fault; keep it out - of the deterministic test surface. */ - if (isErrno(error, 'ENOENT')) { remaining--; continue } - warn(`spill-local: failed to stat ${path}: ${String(error)}`) - continue - /* v8 ignore stop */ - } - // Only regular files expire. Symlinks and other special entries are skipped - // (never followed) so the sweep cannot be redirected or delete a link. - if (!stats.isFile()) continue - if (stats.mtimeMs >= cutoffMs) continue - await unlinkIdempotent(path, warn) - remaining-- - } - return remaining === 0 -} - -/** - * Best-effort one-shot cleanup: across each root, delete expired regular files - * under its `session-*` directories and prune any directory left empty. The - * sweep is idempotent and safe to run concurrently with live spill writes and - * with another process's sweep — per-file expiry preserves a fresh write even - * if it lands mid-sweep, and every filesystem failure is caught and reported - * rather than thrown, so a caller can await this during activation/disposal - * without it ever rejecting. - * - * @param options The roots to sweep, the age cutoff, and the failure sink. - * @returns Resolves when the sweep finishes (never rejects). - */ -export async function sweepSpillRoots(options: SweepOptions): Promise { - const { roots, cutoffMs, warn } = options - for (const root of roots) { - let entries: string[] - try { - entries = await readdir(root.path) - } catch (error: unknown) { - // A root that does not exist yet (no spill ever written) is the common - // case, not an error: ENOENT is silent, anything else is reported. - if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to read root ${root.path}: ${String(error)}`) - continue - } - // Track whether the root holds ANY entry the sweep did not fully reclaim, so - // a discovered prior-default root can be pruned only when nothing remains. - let rootEmptiable = true - for (const name of entries) { - // Only the backend's own `session-<12 hex>` directories are swept; an - // unrelated sibling (`session-backup`, a stray file) is left untouched and - // blocks pruning the root. - if (!SESSION_DIR_RE.test(name)) { rootEmptiable = false; continue } - const dir = join(root.path, name) - let stats - try { - // lstat the session entry itself: a `session-*` SYMLINK must never be - // followed (readdir/unlink through it would delete files in a foreign - // target). Only a real directory is swept. - stats = await lstat(dir) - } catch (error: unknown) { - /* v8 ignore start -- an entry readdir just returned fails to lstat only - by racing away (ENOENT) or a permission/IO fault; not deterministically - reproducible. */ - if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to stat ${dir}: ${String(error)}`) - continue - /* v8 ignore stop */ - } - if (!stats.isDirectory()) { rootEmptiable = false; continue } - const empty = await sweepSessionDir(dir, cutoffMs, warn) - if (!empty) { rootEmptiable = false; continue } - try { - await rmdir(dir) - } catch (error: unknown) { - /* v8 ignore start -- prune runs only on a dir observed empty; a failure - here means a concurrent writer added a file (ENOTEMPTY) or a - permission/IO fault struck — both are races outside deterministic - in-process testing. */ - rootEmptiable = false - if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { - warn(`spill-local: failed to prune ${dir}: ${String(error)}`) - } - /* v8 ignore stop */ - } - } - // A discovered prior-default root (one per past process) is removed once its - // last session dir is gone — otherwise empty roots accumulate forever and - // every future startup rescans them. The active/configured root is never - // pruned (the live process is still writing into it). - if (root.pruneWhenEmpty && rootEmptiable) { - try { - await rmdir(root.path) - } catch (error: unknown) { - /* v8 ignore start -- prune runs only on a root whose every child was - reclaimed; a failure here means a concurrent writer added a fresh - spill after our scan (ENOTEMPTY) or removed the root already (ENOENT) - or a permission/IO fault struck — all races outside deterministic - in-process testing. */ - if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) { - warn(`spill-local: failed to prune root ${root.path}: ${String(error)}`) - } - /* v8 ignore stop */ - } - } - } -} - -/** - * Discover prior default spill roots: the `dsh-spill-<6 chars>` directories - * directly under `base` (the OS tmpdir) that earlier runs created via - * {@link privateRoot} when no `root` was configured. A long-lived deployment - * with a configured root will find none; a series of default-root runs - * accumulates one per process, so the startup sweep reclaims them all. Matching - * is the EXACT `mkdtemp` shape (see {@link DEFAULT_ROOT_RE}), not the bare - * prefix, so an unrelated `dsh-spill-test-*` fixture or a foreign - * differently-shaped directory is never swept; symlinks and non-directories are - * excluded too — only real directories the backend could have created. - * - * @param warn Sink for a failure reading `base` (returns `[]` on failure). - * @param base The directory to scan; defaults to the OS tmpdir (a test seam). - * @returns Absolute paths of the discovered default roots (possibly empty). - */ -export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise { - let entries: string[] - try { - entries = await readdir(base) - } catch (error: unknown) { - warn(`spill-local: failed to scan ${base} for default roots: ${String(error)}`) - return [] - } - const roots: string[] = [] - for (const name of entries) { - if (!DEFAULT_ROOT_RE.test(name)) continue - const path = join(base, name) - let stats - try { - // lstat, not stat: a symlink named `dsh-spill-*` must not be treated as a - // root we then sweep (it could point anywhere). - stats = await lstat(path) - } catch (error: unknown) { - /* v8 ignore start -- an entry readdir just returned fails to lstat only by - racing away (ENOENT) or a permission/IO fault; not deterministically - reproducible. */ - if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to stat default root ${path}: ${String(error)}`) - continue - /* v8 ignore stop */ - } - if (stats.isDirectory()) roots.push(path) - } - return roots + return { path, bytes: Buffer.byteLength(options.content, 'utf8') } } diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index 41d35824c6..8bdb5a7471 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -3,10 +3,10 @@ * returns a locator + byte length + retrieval hint, filename sanitization * neutralizes traversal, the configured `root` is honored (and the private * default when omitted), and a storage failure rejects. The startup cleanup - * sweep expires old files, prunes empty dirs, skips symlinks/unknown entries, + * sweep expires old files, prunes stale roots, skips symlinks/unknown entries, * discovers prior default roots, contains filesystem failures, and is awaited on - * disposal without blocking activation. The Cordis-free `store.ts` helpers are - * exercised directly for the naming/encoding and sweep edge cases. + * disposal without blocking activation. The Cordis-free store and cleanup + * helpers are exercised directly for their edge cases. */ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' @@ -280,7 +280,7 @@ describe('startup cleanup sweep', () => { expect(existsSync(old)).toBe(true) }) - it('prunes a session directory left empty, keeps one with a surviving file', async () => { + it('keeps active session directories after deleting expired files', async () => { const emptied = sessionDir(root, 'emptied') const kept = sessionDir(root, 'kept') mkdirSync(emptied, { recursive: true }) @@ -288,7 +288,7 @@ describe('startup cleanup sweep', () => { writeAged(join(emptied, 'a.txt'), 'x', 40) writeAged(join(kept, 'fresh.txt'), 'y', 1) await runSweep([active(root)]) - expect(existsSync(emptied)).toBe(false) + expect(existsSync(emptied)).toBe(true) expect(existsSync(kept)).toBe(true) }) @@ -351,7 +351,7 @@ describe('startup cleanup sweep', () => { await runSweep([{ path: prior, pruneWhenEmpty: true }, active(root)]) expect(existsSync(prior)).toBe(false) // discovered root pruned expect(existsSync(root)).toBe(true) // active root kept - expect(existsSync(activeDir)).toBe(false) // its emptied session dir still pruned + expect(existsSync(activeDir)).toBe(true) // active session dirs remain writable } finally { rmSync(prior, { recursive: true, force: true }) } @@ -458,6 +458,13 @@ describe('startup cleanup sweep', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root')) }) + it('contains an exception from the warning sink', async () => { + const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x') + const warn = vi.fn(() => { throw new Error('logger failed') }) + await expect(sweepSpillRoots({ roots: [active(filePath)], cutoffMs: Date.now(), warn })).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledOnce() + }) + it('a nonexistent root is silent (the common no-spill-yet case)', async () => { const warn = vi.fn() await sweepSpillRoots({ roots: [active(join(root, 'never-created'))], cutoffMs: Date.now(), warn }) @@ -500,4 +507,3 @@ describe('isErrno', () => { expect(isErrno(new Error('no code'), 'ENOENT')).toBe(false) }) }) - From 5c799a95201ad52ea2ea1fc05a2ccbb5b7cadc19 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 15:44:42 +0800 Subject: [PATCH 049/138] =?UTF-8?q?fix(attachment):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20Windows=20=E5=8F=AA=E8=AF=BB=E5=8F=91=E5=B8=83=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 46 ++++++++++--------- docs/module-graph.zh.md | 46 ++++++++++--------- .../attachment/attachment-local/src/store.ts | 8 ++-- .../attachment-local/tests/store.spec.ts | 8 ++++ 5 files changed, 63 insertions(+), 49 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index b8a9b43bd1..8ba88343ec 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: d70aa9a7704a7de5b669928a6cafd8358fb2a3b0 -module-graph.zh.md: 2333d71e61bd935fa482fc766bb7d96bb75d56db +module-graph.md: aeb35195f3a095b9de694f164dc1111acf5c8197 +module-graph.zh.md: ef3bc3fbce9cad37542eeea6adf936dd70e6581b diff --git a/docs/module-graph.md b/docs/module-graph.md index d70aa9a770..aeb35195f3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -439,18 +439,6 @@ flowchart TD pkg_settings_file --> pkg_home_paths pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_brand - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -470,14 +458,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_llm_pi_ai --> pkg_attachment - pkg_llm_pi_ai --> pkg_authorization - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_launch_environment - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -653,6 +633,28 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_brand + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_authorization + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_fs + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_launch_environment + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout pkg_plugin_package_inventory_deepseek --> pkg_agent pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions @@ -1696,14 +1698,12 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1745,6 +1745,8 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2333d71e61..ef3bc3fbce 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -441,18 +441,6 @@ flowchart TD pkg_settings_file --> pkg_home_paths pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_brand - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -472,14 +460,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_llm_pi_ai --> pkg_attachment - pkg_llm_pi_ai --> pkg_authorization - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_launch_environment - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -655,6 +635,28 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_brand + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_authorization + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_fs + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_launch_environment + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout pkg_plugin_package_inventory_deepseek --> pkg_agent pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions @@ -1698,14 +1700,12 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1747,6 +1747,8 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index f5463a478f..e5a979aec1 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -222,8 +222,11 @@ export async function commitPreparedImageFile( const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } - // The hard link and staging entry share one inode, so this applies to both - // names until cleanup and also enforces read-only mode on deduplicated objects. + // Windows shares the read-only attribute across hard links and refuses to + // unlink either name once it is set, so discard the staging name first. + await unlink(temporary) + // The target remains the sole link for a new object; this also restores + // read-only mode when the deduplication path observes an existing object. await chmod(target, 0o400) // Persist the target entry and close a concurrent bucket-creation window // before the reference can reach a session checkpoint. The dedup path @@ -231,7 +234,6 @@ export async function commitPreparedImageFile( // that writer reaches its own durability boundary. await syncDirectory(bucket) await syncDirectory(join(root, 'objects')) - await unlink(temporary) } catch (error) { /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ if (handle !== undefined) await handle.close().catch( diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 64cca74b7e..478c3f6d50 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -141,6 +141,14 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it.skipIf(process.platform !== 'win32')('publishes a new object on Windows', async () => { + const storageRoot = await root() + + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) + }) + it('stores the normalized image of an oversized source and reads it back verified', async () => { const storageRoot = await root() const oversized = new Uint8Array(await sharp({ From 7bad88206b26b9360abe51739b38f89756805b7d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 16:01:49 +0800 Subject: [PATCH 050/138] =?UTF-8?q?test(llm):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E7=8E=AF=E5=A2=83=E5=9B=BE=E7=89=87=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../llm-deepseek/tests/dynamic-config.spec.ts | 20 ++++++++++++++++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 22 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index fd6759c152..75cb90000b 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -30,6 +30,18 @@ const IMAGE_REF: ImageAttachmentRef = { width: 1, height: 1, } +const HOST_IMAGE_PATH = '/host/.dsh/attachments/objects/aa/object' +const MODEL_IMAGE_PATH = '/model/.dsh/attachments/objects/aa/object' + +class MappedFileSystem extends Service { + constructor(ctx: Context) { + super(ctx, 'fs') + } + + processPathFromHostPath(hostPath: string): string | undefined { + return hostPath === HOST_IMAGE_PATH ? MODEL_IMAGE_PATH : undefined + } +} class StaticAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { @@ -53,6 +65,10 @@ class StaticAttachmentStore extends AttachmentStore { return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) } + override imageHostPath(_ref: ImageAttachmentRef): string { + return HOST_IMAGE_PATH + } + override readImageRequest( ref: ImageAttachmentRef, _policy: ImageRequestPolicy, @@ -193,6 +209,7 @@ describe('request-level dynamic configuration', () => { { kind: 'sse', events: textEvents }, ]) const { ctx } = await boot(dir, { baseURL: server.url }) + await ctx.plugin(MappedFileSystem) const messages = [createUserMessage({ content: [ { type: 'image', attachment: IMAGE_REF }, @@ -209,6 +226,7 @@ describe('request-level dynamic configuration', () => { const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2) expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits') + expect(JSON.stringify(second)).toContain(MODEL_IMAGE_PATH) expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index b86f1d93ca..21d5b2c486 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, @@ -31,6 +31,18 @@ const IMAGE_REF: ImageAttachmentRef = { width: 1, height: 1, } +const HOST_IMAGE_PATH = '/host/.dsh/attachments/objects/aa/object' +const MODEL_IMAGE_PATH = '/model/.dsh/attachments/objects/aa/object' + +class MappedFileSystem extends Service { + constructor(ctx: Context) { + super(ctx, 'fs') + } + + processPathFromHostPath(hostPath: string): string | undefined { + return hostPath === HOST_IMAGE_PATH ? MODEL_IMAGE_PATH : undefined + } +} async function harness(baseURL: string, overrides: Record = {}): Promise { vi.stubEnv('PI_TEST_KEY', 'test-key') @@ -228,7 +240,7 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) - it('resolves an attachment service mounted after the adapter when dispatching an image', async () => { + it('resolves attachment and filesystem services mounted after the adapter when dispatching an image', async () => { const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`) const ref: ImageAttachmentRef = { @@ -281,6 +293,10 @@ describe('PiAiAdapter provider routing', () => { return readImage(value) } + override imageHostPath(_ref: ImageAttachmentRef): string { + return HOST_IMAGE_PATH + } + override readImageRequest( value: ImageAttachmentRef, policy: ImageRequestPolicy, @@ -296,6 +312,7 @@ describe('PiAiAdapter provider routing', () => { providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) await ctx.plugin(LateAttachmentStore) + await ctx.plugin(MappedFileSystem) const result = await assemble(ctx, { provider: 'openai', @@ -311,6 +328,7 @@ describe('PiAiAdapter provider routing', () => { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024, }, expect.any(AbortSignal)) + expect(JSON.stringify(server.requests[0])).toContain(MODEL_IMAGE_PATH) expect(server.paths).toEqual(['/v1/responses']) }) From d61ba08685d132c1a1fbab7f4fc2844f3e60eca4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 16:04:17 +0800 Subject: [PATCH 051/138] fix(client): restore branded running copy --- packages/client/ui-chat/src/client/locale.ts | 2 +- packages/client/ui-chat/tests/chat-view.client.spec.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 9f6a18fd7b..be8a3521ac 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -27,7 +27,7 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', - 'chat.deepDiving': '正在深入处理…', + 'chat.deepDiving': '深度求索中...', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'fileOpen.folderTitle': '无法打开文件夹', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index d6ccc631e6..d112bd779c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -914,7 +914,7 @@ describe('ChatView', () => { const view = render() expect(view.getByTestId('tool-seat-r1')).toBeTruthy() expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' }) - expect(view.getByRole('status').textContent).toBe('正在深入处理…') + expect(view.getByRole('status').textContent).toBe('深度求索中...') }) it('keeps the Tool renderer mounted when a running call settles into log order', () => { @@ -974,7 +974,7 @@ describe('ChatView', () => { const view = render() // Freshly mounted (as after a reload) yet already past the 15s gate. const status = view.getByRole('status') - expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/) + expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() act(() => { h.setSession({ queue: [{ @@ -986,7 +986,7 @@ describe('ChatView', () => { text: 'also', }] }) }) - expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/) + expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) }) it('hands each ordered root call to the keyed business-node slot', () => { From 486389053518878ad4a89925ee7ba13075cc9dd1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 16:14:32 +0800 Subject: [PATCH 052/138] fix(attachment): route image encoding by alpha over shared quality ladders Delete the 5-bit colour-count classifier and palette PNG branch that misrouted high-frequency photographic JPEGs (issue #2885 images 23/24) into an encoder 100x slower with 4x larger output. Both normalization and request-image encoding now route by the decoded alpha fact alone: opaque sources down a JPEG ladder and alpha sources down a WebP effort-0 ladder, each at qualities 85/75/60. Byte budgets become ladder targets: the downscale retry loop is gone and a ladder-exhausted encode keeps its smallest output, while provider byte caps stay enforced at the transmitting route. Request transforms move to request-image-v5. --- ...pha-routed-image-quality-ladders.i18n.yaml | 6 + ...8-24-alpha-routed-image-quality-ladders.md | 31 +++++ ...4-alpha-routed-image-quality-ladders.zh.md | 31 +++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 14 +- docs/config-catalog.zh.md | 12 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 2 +- docs/subsystems/attachment.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 4 +- .../attachment/attachment-local/README.zh.md | 4 +- .../attachment/attachment-local/src/index.ts | 7 +- .../attachment-local/src/normalization.ts | 100 ++++---------- .../attachment-local/src/request-image.ts | 62 +++------ .../attachment-local/tests/index.spec.ts | 6 +- .../tests/normalization-verification.spec.ts | 38 ++++++ .../tests/normalization.spec.ts | 122 +++--------------- .../tests/request-image.spec.ts | 16 ++- .../attachment-local/tests/store.spec.ts | 2 +- packages/attachment/attachment/src/types.ts | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/src/config.ts | 5 +- 26 files changed, 233 insertions(+), 255 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md create mode 100644 packages/attachment/attachment-local/tests/normalization-verification.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml new file mode 100644 index 0000000000..344499741c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.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-24-alpha-routed-image-quality-ladders.md +2026-08-24-alpha-routed-image-quality-ladders.md: 16b3213268999d846f0b2a5ab63b3f5ae4adc332 +2026-08-24-alpha-routed-image-quality-ladders.zh.md: dde9ba60996cdfb779a6a1a88810fec608bc0609 diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md new file mode 100644 index 0000000000..16b3213268 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md @@ -0,0 +1,31 @@ +# Agent Note: Alpha-routed image quality ladders replace colour-count codec routing + +Status: implemented + +English | [中文](2026-08-24-alpha-routed-image-quality-ladders.zh.md) + +## Problem + +Image normalization and request-image encoding in `@deepseek-ai/dsh-attachment-local` chose their codec by a 5-bit colour-count sample: images whose 128×128 nearest-neighbour sample stayed within 256 quantized colours went to palette PNG (libimagequant) before WebP, other alpha images to WebP, and other opaque images to JPEG. High-frequency photographic JPEGs routinely quantize below the threshold — issue #2885's 8000×8000 reproduction images measure 175 and 184 sampled colours against 2145 and 4077 real colours — and palette PNG is the slowest encoder in the pipeline while producing files about four times larger than JPEG on such content (measured 2657ms/3.95MiB versus 26ms/0.95MiB at the 2048px master size). The sample itself forces a full decode (`fastShrinkOnLoad: false`), costing 86 to 192ms on 64MP sources for every image. When every candidate exceeded the byte cap, both encoders also entered a proportional-downscale retry loop ending in an `IMAGE_TOO_LARGE` error, although measured worst-case inputs (uniform noise) fit the default budgets at the first quality. + +## Decision + +Both encoders route by one decoded fact only: sources with an alpha channel encode as lossy WebP at effort 0, opaque sources as JPEG (libjpeg-turbo), each down a shared quality ladder of 85, 75, 60 (`IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` in `normalization.ts`). The colour-count classifier and the palette PNG branch are deleted, not repaired, so the misclassification bug class cannot recur and no image pays the classification decode. `normalizedImageMaxBytes` and the route `maxBytes` become ladder targets rather than caps: the ladder still stops at the first quality that fits, but when every quality exceeds the target the smallest output is kept and the downscale retry loop is gone. Provider byte limits (DeepSeek 32MiB per image, inline budgets) remain enforced where the bytes are transmitted. The request transform version moves to `request-image-v5`, so existing cached variants regenerate by identity; content-addressed masters stay valid without migration. The request cache read no longer rejects entries above the byte target, since a ladder-exhausted output is the deterministic result for its variant id. + +Pareto measurements over the issue #2885 reproduction set (PR #2989 appendices) back the choice: on photographic content JPEG is one to two orders of magnitude faster than every alternative, and WebP at effort 0 matches palette PNG's size on graphics content while never being misrouted; uniform-noise worst cases fit the default 4MiB/1MiB targets at quality 85 for opaque sources, and only an adversarial random-alpha plane exhausts the WebP ladder (about 6.3MiB, five times under the provider cap). + +## Alternatives considered + +**Repair the classifier (higher-resolution sampling, gradient statistics) and keep palette PNG.** Rejected: any content classifier retains a misrouting class and the per-image classification decode; palette PNG's only frontier niche (graphics) is matched by WebP at a fraction of the encode time. + +**A single WebP ladder for everything.** Rejected in review discussion: JPEG is four to six times faster on opaque photographic content, the dominant real workload, and the alpha probe is a metadata read costing nothing. + +**Keep the downscale retry loop for ladder-exhausted outputs.** Rejected: measured worst cases show the loop is dead code within default budgets, and its only reachable effect was degrading adversarial inputs to 1×1 before erroring. + +## Consequences + +- Opaque low-colour graphics (charts, text screenshots) now store as JPEG: two to three times larger than palette PNG in the hundreds-of-kilobytes range, with JPEG ringing on hard edges; the model-visible request version was already dominated by pixel-budget downscaling, so legibility impact is marginal. Reintroducing a graphics codec would add a WebP step to the opaque ladder, not restore classification. +- GIF sources decode with an alpha plane under gifload, so still-frame GIFs normalize onto the WebP ladder. +- `IMAGE_TOO_LARGE` no longer arises from encoding; it remains the admission error for oversized sources. +- A ladder-exhausted attachment can exceed its byte target on disk and on the wire until a provider cap rejects it; measured reachable only with adversarial random-alpha input. +- Test evidence: `packages/attachment/attachment-local/tests` pins the routing, ladder-exhaustion, and readable-text behavior against real encoders, including the issue #2885 misrouting characteristics (high-frequency photographic content leaving the slow path). diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md new file mode 100644 index 0000000000..dde9ba6099 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 按 alpha 路由的图片质量阶梯取代按色数分类的编码路由 + +Status: implemented + +[English](2026-08-24-alpha-routed-image-quality-ladders.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh-attachment-local` 的图片规范化和请求版本编码此前按 5-bit 色数采样选择编码器:128×128 最近邻采样量化后不超过 256 色的图片先走 palette PNG(libimagequant)再退 WebP,其他透明图片走 WebP,其他不透明图片走 JPEG。高频摄影类 JPEG 的量化采样经常落在阈值以下,issue #2885 的 8000×8000 复现图片采样色数为 175 和 184,实际色数为 2145 和 4077,而 palette PNG 是管线里最慢的编码器,在这类内容上产物还比 JPEG 大约 4 倍(2048px master 尺寸实测 2657ms/3.95MiB 对 26ms/0.95MiB)。采样本身因 `fastShrinkOnLoad: false` 必须全尺寸解码,64MP 源图上每张都要付出 86 至 192ms。当所有候选都超过字节上限时,两个编码器还会进入按比例缩图重试的循环,最终以 `IMAGE_TOO_LARGE` 报错,而实测最坏输入(均匀噪声)在默认预算下第一档质量就能装下。 + +## 决定 + +两个编码器只按一个解码事实路由:带 alpha 通道的源图编码为 effort 0 的有损 WebP,不透明源图编码为 JPEG(libjpeg-turbo),共用质量阶梯 85、75、60(`normalization.ts` 的 `IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT`)。色数分类器和 palette PNG 分支被删除而不是修复,误判这一 bug 类别因此不可能复发,也不再有图片付出分类解码成本。`normalizedImageMaxBytes` 和路由 `maxBytes` 的语义从上限改为阶梯目标:阶梯仍在第一个装得下的质量档停下,但全部档位都超过目标时保留最小产物,缩图重试循环被删除。提供方字节硬限制(DeepSeek 单图 32MiB、inline 预算)仍在传输字节的位置执行。请求变换版本升到 `request-image-v5`,已有变体缓存按身份自然重建;内容寻址的 master 无需迁移,继续有效。请求缓存读取不再拒绝超过字节目标的条目,因为阶梯耗尽的产物就是该 variant id 的确定性结果。 + +对 issue #2885 复现集的 Pareto 实测(PR #2989 附录)支撑这个选择:摄影类内容上 JPEG 比其余所有编码器快 1 至 2 个数量级,effort 0 的 WebP 在图形类内容上体积与 palette PNG 相当且不会被误判;均匀噪声最坏输入在不透明链的 q85 一档即落入默认 4MiB/1MiB 目标,只有对抗性的随机 alpha 平面会耗尽 WebP 阶梯(约 6.3MiB,距提供方上限还有 5 倍)。 + +## 考虑过的替代方案 + +**修复分类器(提高采样分辨率、加入梯度统计)并保留 palette PNG。** 否决:任何内容分类器都保留一类误判和每张图的分类解码成本;palette PNG 唯一的前沿生态位(图形类)WebP 用远少的编码时间即可达到。 + +**全部走单一 WebP 阶梯。** 在评审讨论中否决:JPEG 在不透明摄影内容(真实负载的大头)上快 4 至 6 倍,而 alpha 探测只是零成本的元数据读取。 + +**为阶梯耗尽的产物保留缩图重试循环。** 否决:实测最坏情况表明该循环在默认预算内是死代码,其唯一可达效果是把对抗性输入一路缩到 1×1 再报错。 + +## 后果 + +- 不透明的低色数图形(图表、文字截图)现在存为 JPEG:在几百 KB 量级上比 palette PNG 大 2 至 3 倍,锐利边缘有 JPEG 振铃;模型可见的请求版本本就被像素预算缩尺寸主导,可读性影响很小。将来若需要图形类专用编码,正确做法是给不透明阶梯加一档 WebP,而不是恢复分类。 +- GIF 源图经 gifload 解码后带 alpha 平面,因此静帧 GIF 规范化走 WebP 阶梯。 +- `IMAGE_TOO_LARGE` 不再产生于编码环节;它仍是超大源图的准入错误。 +- 阶梯耗尽的附件可能以超过字节目标的大小落盘和上行,直到提供方上限拒绝;实测只有对抗性随机 alpha 输入可达。 +- 测试证据:`packages/attachment/attachment-local/tests` 用真实编码器钉住路由、阶梯耗尽和文字可读性行为,包括 issue #2885 误判特征(高频摄影内容离开慢路径)。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index aed3574106..961b038e2d 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: a845fe22e13ed085765668c7ec8d54d6bbdf129a -config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e +config-catalog.md: 1fdc23a72f8cf1af49c5b02c11c6861fad1c16af +config-catalog.zh.md: e59c647462711504ba1f420a73813db5e921f334 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a845fe22e1..1fdc23a72f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -310,7 +310,10 @@ export interface Config { maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent normalized image. */ normalizedImageMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + /** + * Encoded-byte target of the stored provider-independent normalized image; + * the smallest quality-ladder output is kept when no quality fits. + */ normalizedImageMaxBytes?: number /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number @@ -943,7 +946,7 @@ export interface DeepSeekCatalogModel { inputModalities?: ModelModality[] /** Total-pixel budget for one deterministic request preview. */ imagePixelBudget?: number - /** Encoded-byte cap for one deterministic request preview. */ + /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ imageDetail?: 'auto' | 'low' @@ -1056,7 +1059,10 @@ export interface PiAiProviderProfile { maxRequestImageBytes?: number /** Total-pixel budget for each deterministic inline request version. */ requestImagePixelBudget?: number - /** Raw encoded-byte cap for each deterministic inline request version. */ + /** + * Raw encoded-byte target for each deterministic inline request version; + * the smallest quality-ladder output is used when no quality fits. + */ requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig @@ -1215,7 +1221,7 @@ export type PiAiThinkingFormat = NonNullable diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 39ba9d4836..e59c647462 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -312,7 +312,10 @@ export interface Config { maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent normalized image. */ normalizedImageMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + /** + * Encoded-byte target of the stored provider-independent normalized image; + * the smallest quality-ladder output is kept when no quality fits. + */ normalizedImageMaxBytes?: number /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number @@ -945,7 +948,7 @@ export interface DeepSeekCatalogModel { inputModalities?: ModelModality[] /** Total-pixel budget for one deterministic request preview. */ imagePixelBudget?: number - /** Encoded-byte cap for one deterministic request preview. */ + /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ imageDetail?: 'auto' | 'low' @@ -1058,7 +1061,10 @@ export interface PiAiProviderProfile { maxRequestImageBytes?: number /** Total-pixel budget for each deterministic inline request version. */ requestImagePixelBudget?: number - /** Raw encoded-byte cap for each deterministic inline request version. */ + /** + * Raw encoded-byte target for each deterministic inline request version; + * the smallest quality-ladder output is used when no quality fits. + */ requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index b93c9ef1ca..5bbdecb070 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: e6d0a53db2827a38a1535380319b6220aa37f0a4 -attachment.zh.md: 8328ec610d4d68624f75f00d6a397b13fdf31c4e +attachment.md: 1a8c01ca88f6cf5f252838e3a269f5465dcfcf2a +attachment.zh.md: 97f3bb2d62b60b63484f3e612fd175b9649bbcdc diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index e6d0a53db2..1a8c01ca88 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -98,7 +98,7 @@ interface StoredImageAttachment { interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ maxPixels: number - /** Encoded-byte cap before base64 expansion or Files API upload. */ + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } ``` diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 8328ec610d..97f3bb2d62 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -98,7 +98,7 @@ interface StoredImageAttachment { interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ maxPixels: number - /** Encoded-byte cap before base64 expansion or Files API upload. */ + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } ``` diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 3698abdcb2..545e201707 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-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/attachment/attachment-local/README.md -README.md: 3ed4ab3251b0a609807c76930226bec63f0164cd -README.zh.md: 85abd10389acc46c2d89dd85628f5d201b089710 +README.md: 5f7447aad383a3b5c5d08bb78351471b61a3fc2b +README.zh.md: b58e352bc0401dc486181788c959d70b3c52a1a7 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 3ed4ab3251..5f7447aad3 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 85abd10389..b58e352bc0 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,9 +4,9 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 -请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 +请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index e9a1145ba5..f615bbb187 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -40,7 +40,7 @@ export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 * every later model request without refusing ordinary large sources. */ export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 2048 -/** Default independent safety cap for one stored normalized image. */ +/** Default encoded-byte target for one stored normalized image. */ export const DEFAULT_NORMALIZED_IMAGE_MAX_BYTES = 4 * 1024 * 1024 /** Conservative default number of simultaneous native image transformations per store. */ export const DEFAULT_IMAGE_COMPRESSION_CONCURRENCY = 2 @@ -63,7 +63,10 @@ export interface Config { maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent normalized image. */ normalizedImageMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + /** + * Encoded-byte target of the stored provider-independent normalized image; + * the smallest quality-ladder output is kept when no quality fits. + */ normalizedImageMaxBytes?: number /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts index e9ecd8d3e7..4d679912a8 100644 --- a/packages/attachment/attachment-local/src/normalization.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -11,7 +11,7 @@ import type { DetectedImage } from './image.ts' export interface NormalizationPolicy { /** Long-edge cap in pixels; larger sources are downscaled proportionally. */ maxDimension: number - /** Independent safety cap for encoded normalized image bytes. */ + /** Encoded-byte target for the quality ladder; the smallest ladder output is kept when no quality fits. */ maxBytes: number } @@ -23,23 +23,20 @@ export interface NormalizedImage { height: number } -const NORMALIZATION_QUALITIES = [85, 80, 75] as const -const LOW_COLOUR_SAMPLE_EDGE = 128 -const LOW_COLOUR_LIMIT = 256 -const MIN_SCALE_STEP = 0.9 +/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */ +export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const +/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */ +export const WEBP_ENCODING_EFFORT = 0 /** Encode one prepared pipeline and report exact output facts. */ async function encode( pipeline: Sharp, - mediaType: 'image/png' | 'image/jpeg' | 'image/webp', - quality?: number, - palette = true, + mediaType: 'image/jpeg' | 'image/webp', + quality: number, ): Promise { - const encoded = mediaType === 'image/png' - ? pipeline.png({ compressionLevel: 9, palette }) - : mediaType === 'image/webp' - ? pipeline.webp({ quality }) - : pipeline.jpeg({ quality }) + const encoded = mediaType === 'image/webp' + ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT }) + : pipeline.jpeg({ quality }) const { data, info } = await encoded.toBuffer({ resolveWithObject: true }) return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } } @@ -65,32 +62,6 @@ export function canPassThroughNormalization( && Math.max(detected.width, detected.height) <= policy.maxDimension } -/** - * Classify a bounded pixel sample without assuming that a PNG source is a screenshot. - * @param pipeline - oriented sRGB source pipeline before output resizing. - * @returns whether the nearest-neighbour sample stays within the low-color threshold. - */ -export async function hasLowColourCount(pipeline: Sharp): Promise { - const { data, info } = await pipeline.clone().resize({ - width: LOW_COLOUR_SAMPLE_EDGE, - height: LOW_COLOUR_SAMPLE_EDGE, - fit: 'inside', - withoutEnlargement: true, - kernel: sharp.kernel.nearest, - fastShrinkOnLoad: false, - }).raw().toBuffer({ resolveWithObject: true }) - const colours = new Set() - for (let offset = 0; offset < data.length; offset += info.channels) { - const red = data.readUInt8(offset) - const green = data.readUInt8(offset + 1) - const blue = data.readUInt8(offset + 2) - const alpha = info.channels === 4 ? data.readUInt8(offset + 3) : 255 - colours.add(((red >> 3) << 15) | ((green >> 3) << 10) | ((blue >> 3) << 5) | (alpha >> 3)) - if (colours.size > LOW_COLOUR_LIMIT) return false - } - return true -} - /** Assert that a normalized output is an 8-bit sRGB/sRGBA single-frame image with matching facts. */ async function verifyNormalizedImage( image: NormalizedImage, @@ -130,32 +101,26 @@ function initialDimensions(detected: DetectedImage, maxDimension: number): { wid } } -/** Lazy encoding order for one size, separated by sampled colour complexity and alpha. */ -function encodingAttemptsAtSize( +/** Lazy quality ladder: WebP keeps a source alpha channel, everything else is JPEG. */ +function encodingAttempts( data: Uint8Array, width: number, height: number, hasAlpha: boolean, - lowColour: boolean, ): Array<() => Promise> { const prepared = preparedPipeline(data, width, height) - const webp = NORMALIZATION_QUALITIES.map(quality => ( - () => encode(prepared.clone(), 'image/webp', quality) - )) - if (lowColour) { - return [() => encode(prepared.clone(), 'image/png', undefined, !hasAlpha), ...webp] - } - if (hasAlpha) return webp - return NORMALIZATION_QUALITIES.map(quality => ( - () => encode(prepared.clone(), 'image/jpeg', quality) + const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' + return IMAGE_ENCODING_QUALITIES.map(quality => ( + () => encode(prepared.clone(), mediaType, quality) )) } /** * Produce the persisted provider-independent normalized version of one fully decoded source. * The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA, - * and inside both normalization limits. Re-encoding never removes transparency. After the fixed - * quality floor is reached, dimensions continue shrinking until the independent byte cap holds. + * and inside both normalization limits. Re-encoding never removes transparency. When every + * ladder quality exceeds the byte target, the smallest ladder output is kept; provider byte + * caps stay enforced at the route that transmits the bytes. * @param data - complete admitted source bytes. * @param detected - fully decoded source facts. * @param policy - resolved independent normalization limits. @@ -170,27 +135,13 @@ export async function normalizeImage( return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } } try { - let { width, height } = initialDimensions(detected, policy.maxDimension) - const classificationPipeline = sharp(data, { failOn: 'error', limitInputPixels: false }) - .rotate() - .toColourspace('srgb') - const lowColour = await hasLowColourCount(classificationPipeline) - for (;;) { - const encoded = await encodeFirstWithinLimit( - encodingAttemptsAtSize(data, width, height, detected.hasAlpha, lowColour), - policy.maxBytes, - ) - if (!isExhaustedEncoding(encoded)) { - return await verifyNormalizedImage(encoded, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) - } - if (width === 1 && height === 1) break - const sizeScale = Math.sqrt(policy.maxBytes / encoded.smallest.data.byteLength) * 0.95 - const scale = Math.min(MIN_SCALE_STEP, sizeScale) - const nextWidth = Math.max(1, Math.floor(width * scale)) - const nextHeight = Math.max(1, Math.floor(height * scale)) - width = nextWidth - height = nextHeight - } + const { width, height } = initialDimensions(detected, policy.maxDimension) + const encoded = await encodeFirstWithinLimit( + encodingAttempts(data, width, height, detected.hasAlpha), + policy.maxBytes, + ) + const chosen = isExhaustedEncoding(encoded) ? encoded.smallest : encoded + return await verifyNormalizedImage(chosen, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) } catch (error) { if (error instanceof AttachmentError) throw error const source = detected.mediaType === 'image/png' && detected.depth !== 'uchar' @@ -202,5 +153,4 @@ export async function normalizeImage( { cause: error }, ) } - throw new AttachmentError('Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE') } diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index 66c427480b..1764eded3a 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -12,14 +12,12 @@ import type { RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { hasLowColourCount } from './normalization.ts' +import { IMAGE_ENCODING_QUALITIES, WEBP_ENCODING_EFFORT } from './normalization.ts' import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ -export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v4' -/** DeepSeek request versions normally fit at these two preferred qualities. */ -export const REQUEST_IMAGE_QUALITIES = [85, 80] as const +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v5' interface EncodedRequestImage { data: Uint8Array @@ -87,10 +85,10 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): routePixelBudget: policy.maxPixels, encodedByteBudget: policy.maxBytes, encoding: { - png: { compressionLevel: 9, palette: 'opaque-only' }, - webpQualities: REQUEST_IMAGE_QUALITIES, - jpegQualities: REQUEST_IMAGE_QUALITIES, - order: ['low-colour:png-webp', 'alpha:webp', 'opaque:jpeg'], + webpQualities: IMAGE_ENCODING_QUALITIES, + webpEffort: WEBP_ENCODING_EFFORT, + jpegQualities: IMAGE_ENCODING_QUALITIES, + order: ['alpha:webp', 'opaque:jpeg'], colourspace: 'srgb', }, }) @@ -120,15 +118,12 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp { async function encoded( image: Sharp, - mediaType: 'image/png' | 'image/jpeg' | 'image/webp', - quality?: number, - palette = true, + mediaType: 'image/jpeg' | 'image/webp', + quality: number, ): Promise { - const output = mediaType === 'image/png' - ? image.png({ compressionLevel: 9, palette }) - : mediaType === 'image/webp' - ? image.webp({ quality }) - : image.jpeg({ quality }) + const output = mediaType === 'image/webp' + ? image.webp({ quality, effort: WEBP_ENCODING_EFFORT }) + : image.jpeg({ quality }) const { data, info } = await output.toBuffer({ resolveWithObject: true }) return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } } @@ -138,16 +133,11 @@ function encodingAttempts( width: number, height: number, hasAlpha: boolean, - lowColour: boolean, ): Array<() => Promise> { const prepared = pipeline(attachment, width, height) - const webp = REQUEST_IMAGE_QUALITIES.map(quality => ( - () => encoded(prepared.clone(), 'image/webp', quality) - )) - if (lowColour) return [() => encoded(prepared.clone(), 'image/png', undefined, !hasAlpha), ...webp] - if (hasAlpha) return webp - return REQUEST_IMAGE_QUALITIES.map(quality => ( - () => encoded(prepared.clone(), 'image/jpeg', quality) + const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' + return IMAGE_ENCODING_QUALITIES.map(quality => ( + () => encoded(prepared.clone(), mediaType, quality) )) } @@ -156,7 +146,7 @@ async function createRequestImage( policy: ImageRequestPolicy, hasAlpha: boolean, ): Promise { - let dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) + const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) if (dimensions.width === attachment.ref.width && dimensions.height === attachment.ref.height && attachment.data.byteLength <= policy.maxBytes) { @@ -167,21 +157,11 @@ async function createRequestImage( height: attachment.ref.height, } } - const lowColour = await hasLowColourCount(sourcePipeline(attachment)) - for (;;) { - const encodedVersion = await encodeFirstWithinLimit( - encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha, lowColour), - policy.maxBytes, - ) - if (!isExhaustedEncoding(encodedVersion)) return encodedVersion - if (dimensions.width === 1 && dimensions.height === 1) break - const scale = Math.min(0.9, Math.sqrt(policy.maxBytes / encodedVersion.smallest.data.byteLength) * 0.95) - dimensions = { - width: Math.max(1, Math.floor(dimensions.width * scale)), - height: Math.max(1, Math.floor(dimensions.height * scale)), - } - } - throw new AttachmentError('Image cannot be encoded within the model-request byte budget.', 'IMAGE_TOO_LARGE') + const encodedVersion = await encodeFirstWithinLimit( + encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha), + policy.maxBytes, + ) + return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion } function cachePath(root: string, hash: string): string { @@ -199,7 +179,7 @@ async function readCached( const data = new Uint8Array(await readFile(path, { signal })) const detected = await probeImage(data) const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) - if (data.byteLength > policy.maxBytes || detected.depth !== 'uchar' || detected.space !== 'srgb' + if (detected.depth !== 'uchar' || detected.space !== 'srgb' || detected.width > maximum.width || detected.height > maximum.height || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha } diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index f8deea3c5c..4c10fa5033 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -108,15 +108,15 @@ describe('local attachment service', () => { it('prepares every batch member before any write', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-')) try { - const service = new LocalAttachmentStore(new Context(), { dshHome, normalizedImageMaxBytes: 1 }) + const service = new LocalAttachmentStore(new Context(), { dshHome }) const valid = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) await expect(service.saveImages([ { data: valid, mediaType: 'image/png' }, - { data: valid, mediaType: 'image/png' }, - ])).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + { data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }, + ])).rejects.toThrow(/Unsupported or malformed image data/) expect(existsSync(service.root)).toBe(false) } finally { await rm(dshHome, { recursive: true, force: true }) diff --git a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts new file mode 100644 index 0000000000..4e39223f24 --- /dev/null +++ b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts @@ -0,0 +1,38 @@ +import sharp from 'sharp' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const control = vi.hoisted(() => ({ mismatch: false })) + +vi.mock('../src/image.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async detectImage(data: Uint8Array): Promise>> { + const detected = await actual.detectImage(data) + return control.mismatch ? { ...detected, width: detected.width + 1 } : detected + }, + } +}) + +import { normalizeImage } from '../src/normalization.ts' +import { detectImage } from '../src/image.ts' + +afterEach(() => { + control.mismatch = false +}) + +describe('normalization verification', () => { + it('rejects a normalized output whose decoded facts disagree with the encoder result', async () => { + const data = new Uint8Array(await sharp({ + create: { width: 10, height: 6, channels: 3, background: { r: 12, g: 200, b: 64 } }, + }).png().toBuffer()) + const detected = await detectImage(data) + control.mismatch = true + + await expect(normalizeImage(data, detected, { maxDimension: 5, maxBytes: 4 * 1024 * 1024 })) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', + }) + }) +}) diff --git a/packages/attachment/attachment-local/tests/normalization.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts index d43ae45bcd..6403e1a0b4 100644 --- a/packages/attachment/attachment-local/tests/normalization.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import sharp from 'sharp' -import { hasLowColourCount, canPassThroughNormalization, normalizeImage } from '../src/normalization.ts' +import { canPassThroughNormalization, normalizeImage } from '../src/normalization.ts' import type { NormalizationPolicy } from '../src/normalization.ts' import { detectImage } from '../src/image.ts' @@ -72,14 +72,14 @@ describe('normalizeImage', () => { }) }) - it('downscales an oversized PNG to the long-edge target and stays PNG', async () => { + it('downscales an oversized opaque PNG to the long-edge target as JPEG', async () => { const data = await flatImage(10, 6, 'png') const detected = await detectImage(data) const normalized = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) - expect(normalized).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) const again = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(again.data).toEqual(normalized.data) }) @@ -93,23 +93,26 @@ describe('normalizeImage', () => { expect(second.data).toBe(first.data) }) - it('always re-encodes GIF to the PNG of its first frame', async () => { + it('always re-encodes GIF as a single still frame', async () => { const data = await flatImage(6, 4, 'gif') const detected = await detectImage(data) const normalized = await normalizeImage(data, detected, POLICY) - expect(normalized.mediaType).toBe('image/png') - await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + // gifload always decodes to RGBA, so a GIF re-encodes on the WebP ladder. + expect(detected.hasAlpha).toBe(true) + expect(normalized.mediaType).toBe('image/webp') + await expect(detectImage(normalized.data)).resolves.toMatchObject({ width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) }) - it('keeps a low-colour alpha source on PNG when the budget holds', async () => { + it('keeps a transparent source on the WebP ladder', async () => { const data = await flatImage(9, 5, 'webp', true) const detected = await detectImage(data) const normalized = await normalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) - expect(normalized).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 4, height: 2 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true }) }) it('accepts WebP output that omits an all-opaque source alpha plane', async () => { @@ -137,7 +140,7 @@ describe('normalizeImage', () => { await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: false }) }) - it('keeps transparency when the byte cap requires another encoding and smaller dimensions', async () => { + it('keeps the smallest transparent ladder output above an unreachable byte target without shrinking', async () => { const side = 128 const pixels = new Uint8Array(side * side * 4) const noise = noisePixels(side, side) @@ -153,8 +156,8 @@ describe('normalizeImage', () => { const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) - expect(normalized.data.byteLength).toBeLessThanOrEqual(1_024) - expect(normalized.width).toBeLessThan(side) + expect(normalized.data.byteLength).toBeGreaterThan(1_024) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: side, height: side }) await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) }) @@ -167,10 +170,7 @@ describe('normalizeImage', () => { expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) }) - it('classifies a photographic PNG by pixels and uses an opaque photographic encoding', async () => { - // A smooth gradient: palette quantization dithers it into a sizable PNG - // while JPEG at quality 85 stays far smaller, so the budget between the - // two forces exactly one ladder hop. + it('re-encodes an opaque gradient PNG as JPEG within the byte target', async () => { const side = 256 const pixels = new Uint8Array(side * side * 3) for (let y = 0; y < side; y += 1) { @@ -192,14 +192,13 @@ describe('normalizeImage', () => { expect(normalized.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) }) - it('shrinks dimensions after the quality floor instead of refusing an oversized encoding', async () => { + it('keeps the smallest opaque ladder output above an unreachable byte target', async () => { const data = await noiseImage(64, 64, 'png') const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) - expect(normalized.data.byteLength).toBeLessThanOrEqual(512) - expect(normalized.width).toBeLessThan(64) - expect(normalized.height).toBeLessThan(64) + expect(normalized.data.byteLength).toBeGreaterThan(512) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 64, height: 64 }) }) it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => { @@ -263,86 +262,7 @@ describe('normalizeImage', () => { }) }) - it('rejects a converted normalized image whose verified alpha metadata disagrees with the source facts', async () => { - const data = await flatImage(8, 8, 'png', true) - const detected = await detectImage(data) - - await expect(normalizeImage(data, { ...detected, hasAlpha: false }, { - maxDimension: 4, - maxBytes: POLICY.maxBytes, - })).rejects.toMatchObject({ - code: 'ATTACHMENT_WRITE_FAILED', - message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', - }) - }) -}) - -describe('hasLowColourCount', () => { - it('distinguishes photographic rasters from low-colour graphics without averaged sampling', async () => { - const side = 512 - const highFrequency = sharp(noisePixels(side, side), { raw: { width: side, height: side, channels: 3 } }) - const gradientPixels = new Uint8Array(side * side * 3) - for (let y = 0; y < side; y += 1) { - for (let x = 0; x < side; x += 1) { - const offset = (y * side + x) * 3 - gradientPixels[offset] = x & 0xff - gradientPixels[offset + 1] = y & 0xff - gradientPixels[offset + 2] = (x * 3 + y * 5) & 0xff - } - } - const ordinaryPhoto = sharp(gradientPixels, { raw: { width: side, height: side, channels: 3 } }) - const solid = sharp({ - create: { width: side, height: side, channels: 3, background: { r: 12, g: 34, b: 56 } }, - }) - const text = sharp(Buffer.from(` - - - DeepSeek 16-bit - - `)) - const transparentData = await sharp({ - create: { width: side, height: side, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }).composite([{ input: Buffer.from(` - - - - `) }]).png().toBuffer() - const transparent = sharp(transparentData) - - await expect(hasLowColourCount(highFrequency)).resolves.toBe(false) - await expect(hasLowColourCount(ordinaryPhoto)).resolves.toBe(false) - await expect(hasLowColourCount(solid)).resolves.toBe(true) - await expect(hasLowColourCount(text)).resolves.toBe(true) - await expect(hasLowColourCount(transparent)).resolves.toBe(true) - }) - - it('reads grayscale-alpha samples without treating alpha or the next pixel as RGB', async () => { - const symbols: number[] = [] - for (let first = 0; first < 32; first += 1) { - for (let second = 0; second < 32; second += 1) symbols.push(first, second) - } - const pixels = new Uint8Array(symbols.length * 2) - for (const [index, symbol] of symbols.entries()) { - pixels[index * 2] = symbol * 8 - pixels[index * 2 + 1] = symbol * 8 - } - const grayscaleAlpha = sharp(pixels, { - raw: { width: 128, height: 16, channels: 2 }, - }) - - await expect(hasLowColourCount(grayscaleAlpha)).resolves.toBe(true) - }) - - it('reads one-channel grayscale samples as equal RGB values', async () => { - const pixels = new Uint8Array(128 * 16) - for (let index = 0; index < pixels.length; index += 1) pixels[index] = index & 0xff - - await expect(hasLowColourCount(sharp(pixels, { - raw: { width: 128, height: 16, channels: 1 }, - }))).resolves.toBe(true) - }) - - it('keeps an antialiased text screenshot readable on the low-colour PNG path', async () => { + it('keeps an antialiased text screenshot readable on the JPEG ladder', async () => { const source = new Uint8Array(await sharp(Buffer.from(` @@ -356,7 +276,7 @@ describe('hasLowColourCount', () => { }) const stats = await sharp(normalized.data).greyscale().stats() - expect(normalized).toMatchObject({ mediaType: 'image/png', width: 512, height: 256 }) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 512, height: 256 }) expect(stats.channels[0]?.min).toBeLessThan(80) expect(stats.channels[0]?.max).toBeGreaterThan(240) }) diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 66932b5e52..e470b4c657 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -97,12 +97,15 @@ describe('local request-image cache', () => { .rejects.toThrow('Image request maxBytes must be a positive integer') }) - it('refuses a one-pixel request that cannot meet the encoded-byte budget', async () => { + it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 })) - .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 }) + + expect(request.mediaType).toBe('image/jpeg') + expect(request.bytes).toBeGreaterThan(1) + expect(request).toMatchObject({ width: 1, height: 1 }) }) it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { @@ -171,7 +174,7 @@ describe('local request-image cache', () => { expect(low.width * low.height).toBeLessThanOrEqual(512 * 512 + low.width) }) - it('classifies opaque PNG pixels and preserves alpha while enforcing the request budget', async () => { + it('routes opaque pixels to JPEG and preserves alpha on the WebP ladder', async () => { const attachments = await store() const side = 256 const photoPixels = new Uint8Array(side * side * 3) @@ -204,8 +207,9 @@ describe('local request-image cache', () => { const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 }) expect(photoRequest.mediaType).toBe('image/jpeg') - expect(alphaRequest.bytes).toBeLessThanOrEqual(4_096) - expect(alphaRequest.width).toBeLessThan(128) + expect(alphaRequest.mediaType).toBe('image/webp') + expect(alphaRequest.bytes).toBeGreaterThan(4_096) + expect(alphaRequest).toMatchObject({ width: 128, height: 128 }) await expect(sharp(alphaRequest.data).metadata()).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index ad29f856ec..e1a729d90e 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -149,7 +149,7 @@ describe('local attachment store', () => { }, { ...LIMITS, maxImagePixels: 64 }, { maxDimension: 2, maxBytes: 1024 * 1024 }) expect(saved).toMatchObject({ - mediaType: 'image/png', + mediaType: 'image/jpeg', width: 2, height: 2, name: 'big.png', diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index e23a7a7d4c..046444cd76 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -71,7 +71,7 @@ export interface StoredImageAttachment { export interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ maxPixels: number - /** Encoded-byte cap before base64 expansion or Files API upload. */ + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 8076a692e6..2bf1a55649 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 7d50a8e99863637a06abf49d26a6eeb419cf1bbd -README.zh.md: c1571149529a2d4e54d67b10f63b60bb2aa1abfd +README.md: 5f03254479b81d294944197c55ffec0c59cdc94b +README.zh.md: 463d2de6774c868e7c2daa2e4772c28c8cce9cc0 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7d50a8e998..5f03254479 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -52,7 +52,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: alpha images try WebP (effort 0) at 85, 75, then 60, and opaque images try JPEG at those qualities; when every quality exceeds 1MiB the smallest output is used. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. `maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c157114952..463d2de677 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -52,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:透明图片依次尝试质量 85、75、60 的 WebP(effort 0);非透明图片依次尝试这些质量的 JPEG。全部质量档都超过 1MiB 时使用其中最小的产物。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 `maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index fe461fdb35..086243ad41 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -60,7 +60,7 @@ export interface DeepSeekCatalogModel { inputModalities?: ModelModality[] /** Total-pixel budget for one deterministic request preview. */ imagePixelBudget?: number - /** Encoded-byte cap for one deterministic request preview. */ + /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ imageDetail?: 'auto' | 'low' diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 28e409e734..b01b3521ff 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -169,7 +169,10 @@ export interface PiAiProviderProfile { maxRequestImageBytes?: number /** Total-pixel budget for each deterministic inline request version. */ requestImagePixelBudget?: number - /** Raw encoded-byte cap for each deterministic inline request version. */ + /** + * Raw encoded-byte target for each deterministic inline request version; + * the smallest quality-ladder output is used when no quality fits. + */ requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig From 58a0e450b3d63b2649b8668c46f82d40b609d416 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 24 Aug 2026 11:38:14 +0800 Subject: [PATCH 053/138] perf(token-meter): commit the surface fold in place through a plan/commit pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit foldSurfaceTokens rebuilt the priced surface on every surface event: an append allocated [...nodes, node] and a replacement copied the whole array before splicing, charging every well-formed event O(surface) for an atomicity property only malformed events need. Benchmarks put the copy at ~99.9% of an append's cost (100µs at a 50k-node surface vs 0.1µs for pricing) with O(S²) accumulation over a session, inside the synchronous session/event publication path. Split the fold into the session core's planSurfaceEvent/applySurfacePlan shape: planSurfaceTokens performs every fallible step against the read-only surface, commitSurfaceTokens applies the plan in place and is infallible by construction. _foldEvent plans first, runs the remaining fallible anchor validation, and only then commits, so retry identity is preserved by ordering instead of by allocation. Appends drop to amortized O(1) (100.3µs -> 1.9µs at 50k nodes); replacements keep their O(surface) findIndex but stop paying the extra full copy (21µs -> 4.2µs). A new regression test pins the one hazard this introduces: an event whose surface plan is valid but whose later anchor validation throws must leave the priced surface and running total uncommitted across repeated failures. --- ...n-meter-surface-fold-plan-commit.i18n.yaml | 6 ++ ...24-token-meter-surface-fold-plan-commit.md | 29 ++++++++ ...token-meter-surface-fold-plan-commit.zh.md | 29 ++++++++ ...composer-context-meter-breakdown.i18n.yaml | 4 +- ...-08-05-composer-context-meter-breakdown.md | 4 +- ...-05-composer-context-meter-breakdown.zh.md | 4 +- packages/llm/token-meter/src/index.ts | 20 +++--- packages/llm/token-meter/src/surface-fold.ts | 68 +++++++++++-------- .../llm/token-meter/tests/token-meter.spec.ts | 27 ++++++++ 9 files changed, 148 insertions(+), 43 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.i18n.yaml new file mode 100644 index 0000000000..44ae87702b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.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-24-token-meter-surface-fold-plan-commit.md +2026-08-24-token-meter-surface-fold-plan-commit.md: 878b2634c18e6bfbf5c341260659028f92399e96 +2026-08-24-token-meter-surface-fold-plan-commit.zh.md: 7707bf645ecc26098835e6922f48911927ce36f1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md new file mode 100644 index 0000000000..878b2634c1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md @@ -0,0 +1,29 @@ +# Agent Note: Token-meter surface fold commits in place through a plan/commit pair + +Status: implemented + +English | [中文](2026-08-24-token-meter-surface-fold-plan-commit.zh.md) + +## Problem + +`foldSurfaceTokens` rebuilt the meter's priced surface on every surface event: an append allocated `[...nodes, node]` and a replacement copied the whole array before splicing. The copy existed for one property — a throw must leave the caller's `ReplayState` untouched so a malformed event fails identically on every retry — but it charged every WELL-FORMED event O(surface) for it. Benchmarks on this fold showed the copy was ~99.9% of an append's cost (100µs at a 50k-node surface versus 0.1µs for the pricing itself), and successive appends accumulate O(S²) over a session's life, concentrated in exactly the long sessions users report as sluggish. The token meter folds inside the synchronous `session/event` publication path, so this cost lands on the agent loop's streaming appends. + +## Decision + +Split the fold into the session core's existing `planSurfaceEvent`/`applySurfacePlan` shape: `planSurfaceTokens` performs every fallible step (message pricing, replacement-range resolution) against the read-only surface and returns a `SurfaceTokenPlan`; `commitSurfaceTokens` applies a plan in place — `push` for an append, one `splice` for a replacement — and is infallible by construction. `TokenMeter._foldEvent` plans first, runs the remaining fallible anchor validation (step pairing, provider-chunk provenance), and only then commits, so retry identity is preserved by ordering instead of by allocation. Appends drop from O(surface) to amortized O(1); replacements keep their O(surface) `findIndex` but stop paying the extra full copy. + +`measure()` still detaches its result with `structuredClone` + `deepFreeze`, so in-place mutation of the meter-owned array never escapes to callers. + +## Testing + +The existing malformed-replay suite already pins retry identity (`expectRepeatedFailure` asserts the same throw twice for out-of-range replacements, missing step boundaries, and bad provenance). A new regression test covers the hazard this change introduces: an event whose surface plan is valid but whose later anchor validation throws must leave the priced surface and running total uncommitted across repeated failures — under a mis-ordered in-place commit the throw pattern would still match while the surface silently double-counted. The full token-meter and compaction suites exercise both commit arms through real prune and summary replacements. + +## Alternatives considered + +**A seq→index map to make replacements O(1) too.** Rejected for now: index shifts on every splice force an O(surface) rebuild per replacement anyway, and replacements are orders of magnitude rarer than appends (compaction summaries and prune passes only). The append path was the quadratic term. + +**Keeping the allocation and sharing structurally (persistent vector).** Rejected: a dependency or hand-rolled structure for a single internal array is not justified when the plan/commit ordering already provides the atomicity the copy existed for. + +## Consequences + +The fold no longer contributes a quadratic term to long-session append cost; the meter's remaining per-event costs are the `Session.events` snapshot read in `_sync` (addressed independently by the indexed log-read work, PR #1724/#2907) and O(content) pricing, which is inherent. `SurfaceTokenFold` (the old detached-result type) is gone; `surface-fold.ts` is package-internal, so no external consumer changes. The [composer context-meter note](../feature/2026-08-05-composer-context-meter-breakdown.md) records the projection design around this fold. diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md new file mode 100644 index 0000000000..7707bf645e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md @@ -0,0 +1,29 @@ +# Agent Note:token-meter surface fold 改为 plan/commit 两段式原地提交 + +状态:已实现 + +[English](2026-08-24-token-meter-surface-fold-plan-commit.md) | 中文 + +## 问题 + +`foldSurfaceTokens` 在每个 surface 事件上重建计价 surface:append 分配 `[...nodes, node]`,replacement 先整表复制再 splice。这次复制只为一个性质而存在——抛错必须让调用方的 `ReplayState` 保持原样,使同一条畸形事件在每次重试时以完全相同的方式失败——但它让每条**合法**事件都为此付出 O(surface)。针对该 fold 的基准显示复制占 append 成本的约 99.9%(surface 为 5 万节点时每次 100µs,而估价本身仅 0.1µs),且连续 append 在会话生命周期内累计 O(S²),恰好集中在用户反馈卡顿的长会话上。token meter 在同步的 `session/event` 发布路径内折叠,这笔成本直接落在 agent loop 的流式 append 上。 + +## 决定 + +按 session 核心既有的 `planSurfaceEvent`/`applySurfacePlan` 形态拆分 fold:`planSurfaceTokens` 针对只读 surface 执行所有可失败步骤(消息估价、替换区间解析)并返回 `SurfaceTokenPlan`;`commitSurfaceTokens` 原地应用 plan——append 用 `push`,replacement 用一次 `splice`——并且构造上不可失败。`TokenMeter._foldEvent` 先 plan,再执行剩余的可失败 anchor 校验(step 配对、provider chunk 溯源),最后才 commit,因此重试一致性由执行顺序保证而不再依赖分配。append 从 O(surface) 降为均摊 O(1);replacement 保留 O(surface) 的 `findIndex`,但不再额外整表复制。 + +`measure()` 仍以 `structuredClone` + `deepFreeze` 分离结果,所以对 meter 私有数组的原地修改永远不会泄漏给调用方。 + +## 测试 + +既有的畸形回放套件已钉住重试一致性(`expectRepeatedFailure` 对越界替换、缺失 step 边界、坏溯源各断言两次相同抛错)。新增一个回归测试覆盖本次改动引入的风险点:surface plan 合法但后续 anchor 校验抛错的事件,必须在反复失败后保持计价 surface 与累计总量未提交——若原地提交顺序错误,抛错模式依然匹配而 surface 会悄悄重复计数。完整的 token-meter 与 compaction 套件通过真实的 prune 与 summary 替换覆盖两个 commit 分支。 + +## 曾考虑的替代方案 + +**用 seq→index 映射把 replacement 也做成 O(1)。** 暂缓:每次 splice 引起的下标移动本就迫使映射按替换做 O(surface) 重建,而 replacement 比 append 少几个数量级(仅 compaction 摘要与 prune 批次)。二次项在 append 路径上。 + +**保留分配并用结构共享(持久化向量)。** 否决:为单个内部数组引入依赖或手搓结构并不划算,plan/commit 的顺序已提供复制原本换取的原子性。 + +## 后果 + +该 fold 不再为长会话 append 成本贡献二次项;meter 剩余的每事件成本是 `_sync` 中的 `Session.events` 快照读取(由索引化日志读取工作独立解决,PR #1724/#2907)与固有的 O(内容) 估价。旧的分离结果类型 `SurfaceTokenFold` 已移除;`surface-fold.ts` 为包内部模块,无外部消费者需要变更。[composer 上下文仪表笔记](../feature/2026-08-05-composer-context-meter-breakdown.zh.md)记录了该 fold 周边的投影设计。 diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml index f3a45e4457..876378ec58 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.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-composer-context-meter-breakdown.md -2026-08-05-composer-context-meter-breakdown.md: a757bcbc8bc57f4c3a16f663a9c922155b8bb575 -2026-08-05-composer-context-meter-breakdown.zh.md: 02fcfdf89664dcf932509a0d56193bb4e0d83805 +2026-08-05-composer-context-meter-breakdown.md: 318a1caf7d2494baa9ee72e2719efc22523fdac6 +2026-08-05-composer-context-meter-breakdown.zh.md: 886108dd9cb197afbfc210e3bdbf1f42cf4b1f8f diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md index a757bcbc8b..318a1caf7d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md @@ -14,7 +14,7 @@ Three cooperating pieces, one per package boundary: `dsh-session` exports the pure `deriveEventMessage(event)` (previously reachable only as a `Session` method, which now delegates to it) so a host-side fold can price surface nodes without a `Session` instance. -`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` and its positional surface fold into `src/surface-fold.ts` — both shared verbatim with the measurement service — and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure replays `foldSurfaceTokens` over a per-node `{seq, tokens}` list, so it equals `measure().surfaceTokens` at every event boundary by construction and compaction shrinks it the way it shrinks the next request. The shared fold is total and allocation-fresh — it returns the next surface rather than mutating one — which keeps the service's validate-before-commit replay transaction intact: a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry. A replace range absent from the folded surface throws: committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event. +`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` (shared verbatim with the measurement service) and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure rides the O(1) shadow-price fold in `src/surface-projection.ts`, so on fully metered logs it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it by its logged shadow price. The measurement service's own positional fold lives in `src/surface-fold.ts` as a plan/commit pair ([in-place surface commit](../bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md)): a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry, and a replace range absent from the folded surface throws — committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event. `ui-conversation` moves context occupancy off the stats line (one home per fact) onto a composer-trailing `ContextMeter`: a 14px occupancy ring after the model seat fed by `contextPressure`, click-opening a panel that pairs the provider-exact percent and `~used / capacity` header with a 4px color-segmented bar and `~`-prefixed composition rows. The two vocabularies deliberately never reconcile — the heuristic shares only proportion the bar's colored segments and rows, each marked `~` because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code. (The ring, header, and bar length were provider-exact as shipped here; they now read the provider-anchored `projectedTokens` instead, because the bare sample could not see a compaction — see [the meter's compaction blindness](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md).) The header is one localized sentence (`context.aria`, shared with the ring's accessible name) split around its `{percent}` slot, so each locale owns the reading's position — English leads with it, Chinese trails it — while the reading keeps its own tone; a bar part whose width computes to zero is dropped rather than rendered, because `.segment`'s min-width would otherwise paint a filled sliver at 0% occupancy. @@ -28,4 +28,4 @@ Three cooperating pieces, one per package boundary: ## Consequences -Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 1). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token. +Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 2). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token. diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md index 02fcfdf896..886108dd9c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md @@ -14,7 +14,7 @@ Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N `dsh-session` 导出纯函数 `deriveEventMessage(event)`(此前只能通过 `Session` 方法访问,该方法现在委托给它),使 host 侧 fold 无需 `Session` 实例即可为表层节点计价。 -`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`、把位置表层折叠抽取到 `src/surface-fold.ts`(两者都与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字在逐节点 `{seq, tokens}` 列表上重放 `foldSurfaceTokens`,因此它在每个事件边界上按构造等于 `measure().surfaceTokens`,压缩(compaction)会像缩小下一个请求那样缩小它。这份共享折叠是全函数且总是新建数组——返回下一个表层而不是原地改写——从而保留了服务侧「先校验再提交」的重放事务:抛出时重放游标不前进,同一条畸形事件在重试时报同样的错。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。 +`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`(与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字搭载 `src/surface-projection.ts` 的 O(1) 影子价折叠,因此在完整计量的日志上它在每个事件边界等于 `measure().surfaceTokens`,压缩(compaction)按已记录的影子价缩小它。测量服务自己的位置折叠位于 `src/surface-fold.ts`,是 plan/commit 两段式([原地表层提交](../bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md)):抛出时重放游标不前进,同一条畸形事件在重试时报同样的错;折叠表层中不存在的替换范围会直接抛出——已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。 `ui-conversation` 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 `ContextMeter`:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,点击弹出的面板把提供方精确的百分比与 `~已用 / 容量` 标题与 4px 分色分段进度条及带 `~` 前缀的组成明细行并列。两套口径刻意永不对账——启发式数字只决定进度条各彩色分段之间的相对比例,并原样显示在明细行中;每个数字都标有 `~`,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。(本记录落地时,圆环、标题与进度条总长取的是提供方精确值;它们现在改读锚定在提供方读数上的 `projectedTokens`,因为裸样本看不见压缩——见[仪表对压缩的失明](../bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md)。)标题是一整句本地化文案(`context.aria`,与圆环的无障碍名共用),在 `{percent}` 槽位处切开渲染,于是读数的位置由各语言自己决定——英文在前、中文在后——同时读数保留自身独立的强调样式;宽度算出为零的分段直接不渲染,否则 `.segment` 的 min-width 会在 0% 占用时画出一段填充色。 @@ -28,4 +28,4 @@ Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N ## 后果 -token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 1)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。 +token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 2)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 2fa53f78f1..f0a0a404ef 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -21,7 +21,7 @@ import type { import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts' import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts' import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from './estimate.ts' -import { foldSurfaceTokens } from './surface-fold.ts' +import { commitSurfaceTokens, planSurfaceTokens } from './surface-fold.ts' export type * from './types.ts' @@ -181,9 +181,9 @@ export class TokenMeter extends Service { } /** - * Validate and prepare every fallible part before mutating replay state. - * A malformed event remains unread on every retry instead of partially - * applying the same mutation more than once. + * Run every fallible step — surface plan and anchor validation — before + * mutating replay state, so a malformed event remains unread on every + * retry instead of half-applying. */ private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void { let nextHeader = state.header @@ -214,8 +214,8 @@ export class TokenMeter extends Service { break } - const surface = isSurfaceEvent(event) - ? foldSurfaceTokens(state.surface, event) + const plan = isSurfaceEvent(event) + ? planSurfaceTokens(state.surface, event) : undefined if (event.type === 'assistant/message') { @@ -228,7 +228,7 @@ export class TokenMeter extends Service { // assistant/message is surface-mandatory at every append/seed boundary. // oxlint-disable-next-line typescript/no-non-null-assertion - const eventTokens = surface!.tokens + const eventTokens = plan!.tokens if (event.data.usage !== undefined && nextHeader !== undefined) { const providerAssistantTokens = this._estimateProviderAssistant( session, @@ -262,9 +262,9 @@ export class TokenMeter extends Service { state.header = nextHeader state.stepStart = nextStepStart - if (surface !== undefined) { - state.surface = surface.nodes - state.surfaceTokens += surface.deltaTokens + if (plan !== undefined) { + commitSurfaceTokens(state.surface, plan) + state.surfaceTokens += plan.deltaTokens } state.anchor = nextAnchor } diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index 2848025b19..eb3fdd0451 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -1,12 +1,15 @@ /** * The measurement service's positional surface fold: the per-node priced * surface `measure()` serves and compaction plans against. The projection - * units deliberately do NOT share this fold — their state must stay O(1) - * for the persisted checkpoint, so they ride `surface-projection.ts`'s - * shadow-price protocol instead. Fully metered logs stay in agreement by - * construction: both price through `estimate.ts`, and every logged shadow - * price is derived from THIS fold's nodes by the replace producer. A - * projection replacement without a claim deliberately folds with zero delta. + * units do NOT share this fold — their state must stay O(1) for the + * persisted checkpoint, so they ride `surface-projection.ts`'s shadow-price + * protocol; the two agree because both price through `estimate.ts` and every + * logged shadow price derives from this fold's nodes. + * + * The fold is a plan/commit pair: {@link planSurfaceTokens} runs every + * fallible step read-only and {@link commitSurfaceTokens} mutates in place, + * so a throw leaves the caller's state untouched and the same malformed + * event fails identically on every retry. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ @@ -16,50 +19,61 @@ import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import type { TokenSurfaceNode } from './types.ts' import { estimateMessage } from './estimate.ts' -/** One surface event's placement and cost against the surface preceding it. */ -export interface SurfaceTokenFold { +/** One validated surface transition that has not mutated the priced surface yet. */ +export interface SurfaceTokenPlan { /** Heuristic price of the event's own message; 0 when it derives none. */ readonly tokens: number - /** The surface after the event, detached from the input. */ - readonly nodes: TokenSurfaceNode[] /** Signed change in the surface total: `tokens` minus anything shadowed. */ readonly deltaTokens: number + /** The priced node the commit inserts for this event. */ + readonly node: TokenSurfaceNode + /** Commit position: `append`, or the inclusive replaced index range. */ + readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number } } /** - * Fold one surface event onto a priced surface. - * - * Total and allocation-fresh: the caller assigns the result rather than - * mutating in place, so a throw here leaves the caller's state untouched and - * the same malformed event fails identically on every retry. + * Validate and price one surface event without mutating the surface. * @param nodes - the priced surface preceding this event, in model-visible order. * @param event - the surface event to place. - * @returns the event's price, the next surface, and the signed total delta. + * @returns the plan for {@link commitSurfaceTokens}. * @throws when a replacement names a range absent from `nodes` — committed * logs are surface-validated at append time, so an unresolvable range is log * corruption and must fail loud rather than skip the event. */ -export function foldSurfaceTokens( +export function planSurfaceTokens( nodes: readonly TokenSurfaceNode[], event: SurfaceEvent, -): SurfaceTokenFold { +): SurfaceTokenPlan { const message = deriveEventMessage(event) const tokens = message === null ? 0 : estimateMessage(message) + const node = { seq: event.seq, tokens } const op = event.surfaceOp if (op === 'append') { - return { tokens, nodes: [...nodes, { seq: event.seq, tokens }], deltaTokens: tokens } + return { tokens, deltaTokens: tokens, node, target: 'append' } } - const startIdx = nodes.findIndex(node => node.seq === op.start) - const endIdx = nodes.findIndex(node => node.seq === op.end) + const startIdx = nodes.findIndex(candidate => candidate.seq === op.start) + const endIdx = nodes.findIndex(candidate => candidate.seq === op.end) if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { throw new Error( `token surface: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, ) } - const removed = nodes - .slice(startIdx, endIdx + 1) - .reduce((total, node) => total + node.tokens, 0) - const next = [...nodes] - next.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) - return { tokens, nodes: next, deltaTokens: tokens - removed } + let removed = 0 + // oxlint-disable-next-line typescript/no-non-null-assertion -- startIdx..endIdx are validated indices + for (let index = startIdx; index <= endIdx; index += 1) removed += nodes[index]!.tokens + return { tokens, deltaTokens: tokens - removed, node, target: { startIdx, endIdx } } +} + +/** + * Apply one validated plan to the priced surface in place; infallible, so it + * cannot leave a half-applied surface behind. + * @param nodes - the exact priced surface the plan was built against. + * @param plan - the transition returned by {@link planSurfaceTokens}. + */ +export function commitSurfaceTokens(nodes: TokenSurfaceNode[], plan: SurfaceTokenPlan): void { + if (plan.target === 'append') { + nodes.push(plan.node) + return + } + nodes.splice(plan.target.startIdx, plan.target.endIdx - plan.target.startIdx + 1, plan.node) } diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 074f18fb76..5cd10fe4ce 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -467,6 +467,33 @@ describe('malformed replay and listener lifecycle', () => { expectRepeatedFailure(meter(), session, /no matching step\/start/) }) + it('leaves the priced surface uncommitted when a later validation step rejects the event', () => { + // A valid append plan whose anchor validation throws: only commit + // ordering keeps the surface from double-counting across retries. + const session = Session.create(SessionId('bad-step-surface')) + appendHeader(session, header('deepseek-v4-flash')) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'planned but never committed' }], + source: { + kind: 'model', + ...{ provider: 'mock', model: 'deepseek-v4-flash' }, + }, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [] }) + const service = meter() + const states = (service as unknown as { + states: WeakMap + }).states + expectRepeatedFailure(service, session, /no matching step\/start/) + const state = states.get(session) + expect(state?.surface).toEqual([]) + expect(state?.surfaceTokens).toBe(0) + }) + it('clears completed step boundaries and rejects overlapping or late step events', () => { const overlapping = Session.create(SessionId('overlapping-step')) overlapping.append('step/start', { turn: 1, step: 1 }) From 4db19c352e22153b6808fff75079849e28a4abf7 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 24 Aug 2026 16:22:20 +0800 Subject: [PATCH 054/138] docs(token-meter): distinguish projection and measurement folds --- packages/llm/token-meter/README.i18n.yaml | 4 ++-- packages/llm/token-meter/README.md | 4 ++-- packages/llm/token-meter/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 98b96e634d..4f2d14cdc6 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/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/llm/token-meter/README.md -README.md: a2deab11a31285ba598b8864d3a734ecf7c56620 -README.zh.md: d14cded74691f88db7267ea470f536db85a39218 +README.md: 9cc56c0ac5e445f2de63cb71aa0b0e9354ae8492 +README.zh.md: eb2cfa9b1130c1ff227a284e84ad9afc979cee60 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index a2deab11a3..9cc56c0ac5 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -29,9 +29,9 @@ When the composition provides `ctx.sessionProjections`, token-meter registers th `contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — optional `projectedTokens`, and optional `contextWindow` from the newest `request/context` record. Both figures stay absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so `pressureTokens` holds still while a turn streams and steps forward when the next request reports its usage. -`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero and folded through the same `surface-fold.ts` the measurement service replays. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`. +`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero. Its O(1) fold in `surface-projection.ts` tracks appends and consumes the logged shadow price immediately before a replacement; on fully metered logs it agrees with the measurement service's positional plan/commit fold without retaining per-node prices. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`. -`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays `surface-fold.ts` — the same positional fold `measure()` runs — so it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. +`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. All three units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes all three keys. A composition without the projection seam keeps the measurement service's existing behavior. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index d14cded746..eb2cfa9b11 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -29,9 +29,9 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 `contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和)、可选的 `projectedTokens`,以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前两个数字都保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间 `pressureTokens` 保持不动,等到下一个请求报告用量时才前进。 -`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到再完成一整个轮次为止。占用率展示读取 `projectedTokens`。 +`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,并将下界钳制为零。它在 `surface-projection.ts` 中的 O(1) 折叠会跟踪追加,并消费紧邻替换之前记录的影子价;在完整计量的日志上,它无需保留逐节点价格也能与测量服务的带位置 plan/commit 折叠一致。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到再完成一整个轮次为止。占用率展示读取 `projectedTokens`。 -`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——也就是 `measure()` 运行的同一个带位置 fold——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点所体现的恰好是这些明细行仍然带着的误差(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 +`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点所体现的恰好是这些明细行仍然带着的误差(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。 From a268aada8c89f8a09ce4920d8d65b31542b70eff Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 24 Aug 2026 16:33:15 +0800 Subject: [PATCH 055/138] fix(spill-local): harden startup cleanup --- ...26-07-08-tool-output-spill-files.i18n.yaml | 4 +- .../2026-07-08-tool-output-spill-files.md | 2 +- .../2026-07-08-tool-output-spill-files.zh.md | 2 +- ...7-17-local-spill-startup-cleanup.i18n.yaml | 4 +- .../2026-07-17-local-spill-startup-cleanup.md | 6 +- ...26-07-17-local-spill-startup-cleanup.zh.md | 6 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 6 +- docs/config-catalog.zh.md | 6 +- packages/spill/spill-local/README.i18n.yaml | 4 +- packages/spill/spill-local/README.md | 4 +- packages/spill/spill-local/README.zh.md | 4 +- packages/spill/spill-local/package.json | 2 + packages/spill/spill-local/src/cleanup.ts | 221 ++++++++++++++---- packages/spill/spill-local/src/index.ts | 34 ++- .../tests/loader-composition.spec.ts | 78 +++++++ .../spill-local/tests/spill-local.spec.ts | 102 ++++++-- pnpm-lock.yaml | 6 + 18 files changed, 399 insertions(+), 96 deletions(-) create mode 100644 packages/spill/spill-local/tests/loader-composition.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 20f4e54eae..7b145bd906 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md -2026-07-08-tool-output-spill-files.md: 81a292a00af63b0aa9145a0c556a428ab8c49d64 -2026-07-08-tool-output-spill-files.zh.md: 8d9e60d461297fb11ff2252e91f98a0cfad33f62 +2026-07-08-tool-output-spill-files.md: e14607e388c634c4e2679c993c1b720be0a3a9f3 +2026-07-08-tool-output-spill-files.zh.md: 372c9c6cadf3cd64c3de97a8c305b8909f03caab diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index 81a292a00a..e14607e388 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -57,7 +57,7 @@ interface SpillRef { `SpillLocator` is a [branded](../../../../packages/util/brand) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. `SpillOwner.sessionId` is the save-time storage namespace: forked sessions inherit existing spill locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy. -`dsh-spill-local` owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning `{ locator, bytes, retrievalHint }`. It does not own retention policy, tool-result replacement, search, or file inspection. Files land at `/session-/-`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path. +`dsh-spill-local` owns storage details: session-scoped directory selection, safe names, path-traversal protection, the write, local artifact lifetime, and returning `{ locator, bytes, retrievalHint }`. It does not own tool-result replacement, model-facing preview policy, search, file inspection, or a seam-wide/per-session retention policy. Files land at `/session-/-`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path. Its one-shot startup cleanup applies the backend-specific artifact lifetime described in the [local spill cleanup note](./2026-07-17-local-spill-startup-cleanup.md). ### Spill policy diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index 8d9e60d461..372c9c6cad 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -57,7 +57,7 @@ interface SpillRef { `SpillLocator` 是一个[品牌化的](../../../../packages/util/brand)模型可见句柄,由后端返回。本地后端将其渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方把它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。`SpillOwner.sessionId` 是保存时的存储命名空间:fork 后的会话会从种子日志继承已有的 spill 定位符,无需复制它们或重新取得所有权;fork 后的新 spill 使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;spill seam 不定义逐会话的清理策略。 -`dsh-spill-local` 只负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入,以及返回 `{ locator, bytes, retrievalHint }`。它不负责保留策略、工具结果替换、搜索或文件检查。文件写入 `/session-/-`:`root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。 +`dsh-spill-local` 负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入、本地产物生命周期,以及返回 `{ locator, bytes, retrievalHint }`。它不负责工具结果替换、模型可见的预览策略、搜索、文件检查,也不定义 seam 级或逐会话保留策略。文件写入 `/session-/-`:`root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。它的一次性启动清理会应用[本地 spill 清理说明](./2026-07-17-local-spill-startup-cleanup.zh.md)所述的后端专属产物生命周期。 ### spill 策略 diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml index 511dda5a74..06f4d81cb0 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md -2026-07-17-local-spill-startup-cleanup.md: 96378d6ea785d90385f517c1b9a01073ade47fa2 -2026-07-17-local-spill-startup-cleanup.zh.md: a154cfb824d3a747c2c9acc2b707eb14d703ce2e +2026-07-17-local-spill-startup-cleanup.md: fc64938c1af07d9dd0d7ecec379115d22d1e2464 +2026-07-17-local-spill-startup-cleanup.zh.md: 583a33ead84f552c67e2e770a8b3fabc3ce88120 diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md index 96378d6ea7..fc64938c1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md @@ -12,7 +12,9 @@ The local spill backend never deleted the full tool results it wrote. Every over `dsh-spill-local` runs one best-effort cleanup sweep after activation. It does not delay service availability, is owned by the plugin fiber (a single `ctx.effect` whose generator launches the sweep and yields an async disposer that awaits it), and is awaited during disposal so no sweep I/O outlives the fiber. There is no recurring timer and no separate process. -A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. An invalid value (negative or fractional) throws at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir and deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`. It prunes empty session directories and roots only for discovered prior-default roots; the active root keeps its session directories so pruning cannot race a local write, while writes recreate a session directory if another process prunes a discovered root that is still active. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn`, and a warning-sink exception is also contained — the sweep never throws, so it cannot reject activation or a concurrent spill write. Discovery excludes symlinks and non-directories, returning only real `dsh-spill-*` directories the backend could have created. +A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. Schemastery rejects a negative or fractional value at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir and deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`. It prunes every empty session directory but removes the root itself only for a discovered prior-default root; writes recreate a session directory if pruning races them. Root aliases are de-duplicated by device/inode identity, with the configured identity overriding a discovered match as active and non-prunable. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn`, and a warning-sink exception is also contained — the sweep never throws, so it cannot reject activation or a concurrent spill write. + +Path-based deletion is restricted to directories an untrusted local OS user cannot replace during the scan. On POSIX, every root and session directory must be owned by the current user and not writable by group or others; the root's ancestor path must also be non-writable or protected by a sticky directory such as `/tmp`. Discovery rejects symlinks, while a configured symlink may resolve to a trusted target and participates in identity de-duplication. An unsafe path is skipped with a warning. The same-user account remains the trust boundary, consistent with the backend's private local-storage model. The ctx-free sweep mechanics live in `packages/spill/spill-local/src/cleanup.ts` (`sweepSpillRoots`, `discoverDefaultRoots`), unit-testable without a `ctx`; `store.ts` owns root naming, path derivation, and writes, while the service in `src/index.ts` owns the config, cutoff, and fiber-owned launch/await. @@ -32,4 +34,4 @@ Cleanup cost the backend a startup sweep and a config knob, and bought a bounded ## Testing -`dsh-spill-local` unit tests cover the age boundary (strictly-older expires, boundary kept), `cleanupPeriodDays: 0` disabling, discovered-root pruning, active-directory preservation, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage through the real `gatherRoots`/`discoverDefaultRoots` path, active-root de-duplication, load-time validation of a bad `cleanupPeriodDays`, filesystem- and warning-sink-failure containment both directly and through the service's `ctx.logger.warn` wiring, and the quiescence contract — activation is available while a barrier-held sweep is parked, and disposal only settles after the sweep finishes. +`dsh-spill-local` unit tests cover the exact age boundary, `cleanupPeriodDays: 0` disabling, empty-session and discovered-root pruning, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage, filesystem-identity de-duplication through a configured symlink, unsafe POSIX root/session rejection, load-time config validation, filesystem- and warning-sink-failure containment, and the quiescence contract. A separate test boots the plugin through the real Loader and a cordis.yml, then observes configured expiry and directory pruning after disposal. diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md index a154cfb824..583a33ead8 100644 --- a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md @@ -12,7 +12,9 @@ Status: implemented `dsh-spill-local` 在激活后运行一次尽力而为的清理扫描。它不延迟服务可用性,由插件 fiber 拥有(一个 `ctx.effect`,其生成器启动该扫描并让出一个等待它的异步 disposer),并在 dispose 期间被等待,因此没有扫描 I/O 会存活到 fiber 之后。既没有周期性定时器,也没有独立进程。 -`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。无效值(负数或小数)在加载时抛出。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,并删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件。它只修剪发现的先前默认根目录中的空会话目录和空根目录;活动根目录会保留其会话目录,避免修剪操作与本地写入竞争,而当其他进程修剪了一个仍在使用的发现根目录时,写入操作会重新创建会话目录。扫描使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录,警告接收方抛出的异常也会被兜底——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。发现过程排除符号链接与非目录,只返回后端可能创建过的真实 `dsh-spill-*` 目录。 +`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。Schemastery 会在加载时拒绝负数或小数。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,并删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件。它会修剪所有空会话目录,但只删除发现的先前默认根目录本身;如果修剪与写入发生竞争,写入操作会重新创建会话目录。根目录别名按设备/inode 身份去重,配置目录的身份会覆盖发现的匹配项,并标记为活动且不可删除。扫描使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录,警告接收方抛出的异常也会被兜底——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。 + +基于路径的删除仅限于不受信任的本地 OS 用户无法在扫描期间替换的目录。在 POSIX 上,每个根目录和会话目录都必须由当前用户拥有,且组用户和其他用户不可写;根目录的祖先路径也必须不可写,或由 `/tmp` 这类 sticky 目录保护。发现过程拒绝符号链接,而配置的符号链接可以解析到可信目标并参与身份去重。不安全路径会被跳过并记录警告。与后端的私有本地存储模型一致,同一用户账号仍是信任边界。 无 ctx 依赖的扫描机制位于 `packages/spill/spill-local/src/cleanup.ts`(`sweepSpillRoots`、`discoverDefaultRoots`),无需 `ctx` 即可做单元测试;`store.ts` 负责根目录命名、路径推导与写入,而 `src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。 @@ -32,4 +34,4 @@ Status: implemented ## 验证 -`dsh-spill-local` 单元测试覆盖了年龄边界(严格更旧者过期,边界值保留)、`cleanupPeriodDays: 0` 的禁用、发现根目录的修剪、活动目录的保留、符号链接/无关条目的跳过、通过真实 `gatherRoots`/`discoverDefaultRoots` 路径对配置根加发现根的覆盖、活动根去重、对错误 `cleanupPeriodDays` 的加载期校验、直接测试以及经由服务的 `ctx.logger.warn` 接线测试所覆盖的文件系统与警告接收方失败兜底,以及静止契约:在一个被屏障挂起的扫描停驻期间激活仍然可用,而 dispose 只有在扫描结束后才会完成。 +`dsh-spill-local` 单元测试覆盖了精确年龄边界、`cleanupPeriodDays: 0` 的禁用、空会话目录与发现根目录的修剪、符号链接/无关条目的跳过、配置根加发现根的覆盖、经配置符号链接验证的文件系统身份去重、不安全 POSIX 根目录/会话目录拒绝、加载期配置校验、文件系统与警告接收方故障兜底,以及静止契约。另一个测试会通过真实 Loader 和 cordis.yml 启动插件,并在 dispose 后观察按配置执行的过期与目录修剪。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 66d5592ba2..b334176fb9 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: 9fc8c333510c568686ce43f4aa1f6d0ae6bc3615 -config-catalog.zh.md: 4fb689f63631740d485a854e10a12c6d92c6f4ac +config-catalog.md: 1b0eceb35a7679e17166d1bb0b9a7a8ae6079613 +config-catalog.zh.md: 5a698f82db13fe95535ad631636b921819513a74 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9fc8c33351..1b0eceb35a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2067,8 +2067,10 @@ export interface Config { * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose * `mtime` is strictly older than the cutoff are deleted and emptied * directories are pruned; fresh files, symlinks, and unrelated entries are - * left untouched. Retention is deliberate — a resumed or forked session may - * still reference an older locator until it ages out. + * left untouched. On POSIX, cleanup skips roots and session directories that + * another local user could modify or replace. Retention is deliberate — a + * resumed or forked session may still reference an older locator until it + * ages out. */ cleanupPeriodDays?: number } diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4fb689f636..5a698f82db 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2069,8 +2069,10 @@ export interface Config { * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose * `mtime` is strictly older than the cutoff are deleted and emptied * directories are pruned; fresh files, symlinks, and unrelated entries are - * left untouched. Retention is deliberate — a resumed or forked session may - * still reference an older locator until it ages out. + * left untouched. On POSIX, cleanup skips roots and session directories that + * another local user could modify or replace. Retention is deliberate — a + * resumed or forked session may still reference an older locator until it + * ages out. */ cleanupPeriodDays?: number } diff --git a/packages/spill/spill-local/README.i18n.yaml b/packages/spill/spill-local/README.i18n.yaml index dd414de9b9..1fd753f7ae 100644 --- a/packages/spill/spill-local/README.i18n.yaml +++ b/packages/spill/spill-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/spill/spill-local/README.md -README.md: 75bde20c423e1d2aa4bba49201b5bb0369d34fd0 -README.zh.md: 0539969cc4bd4da8dad4f0ac00436476555fd08c +README.md: e5a1fb08ba438640e649319f42d31aa10afd24c1 +README.zh.md: ba6e2b3c4628b7a21de1361fcd2d1e0a1b573f4b diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md index 75bde20c42..e5a1fb08ba 100644 --- a/packages/spill/spill-local/README.md +++ b/packages/spill/spill-local/README.md @@ -23,7 +23,9 @@ Files land at `/session-/​-`: The backend never deletes a spill on the write path — a persisted, resumed, or forked session may still reference an older locator, so immediate deletion would break retrieval. Instead, one best-effort sweep runs **once after activation**: it does not delay service availability, is owned by the plugin fiber, and is awaited on disposal (no sweep I/O outlives the fiber). There is no recurring timer and no separate process, so a long-lived deployment is not swept again until its next restart. -The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. Within each, it deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`; it prunes empty session directories and roots only for discovered prior-default roots, while the active root keeps its session directories to avoid racing a write. A write recreates its session directory if another process prunes a discovered root that is still active. The sweep never follows or deletes a symlink, skips unrelated entries, and contains every filesystem or warning-sink failure so it cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff. +The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. It resolves each root to its filesystem identity, so a configured alias of a discovered root remains the active, non-prunable root. Within each root, the sweep deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays` and prunes every empty session directory; only an empty discovered prior-default root is itself removed. A write recreates a session directory if cleanup races it. The sweep never follows or deletes a symlink and skips unrelated entries. + +On POSIX, cleanup admits only roots owned by the current user, not writable by group or others, and protected from replacement through their ancestor path; a writable sticky temporary directory such as `/tmp` is permitted. Session directories must satisfy the same ownership and write restrictions. Unsafe paths are skipped with a warning, which prevents an untrusted local process from redirecting path-based deletion outside the spill root. Every filesystem or warning-sink failure is contained, so cleanup cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff. `saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design, and the [startup-cleanup Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md) for the sweep. diff --git a/packages/spill/spill-local/README.zh.md b/packages/spill/spill-local/README.zh.md index 0539969cc4..ba6e2b3c46 100644 --- a/packages/spill/spill-local/README.zh.md +++ b/packages/spill/spill-local/README.zh.md @@ -23,7 +23,9 @@ 后端不会在写入路径上删除 spill,因为已持久化、已恢复或 fork 后的会话仍可能引用较旧的定位信息,立即删除会使其无法取回。后端会改为在激活后**仅运行一次**尽力而为的扫描:扫描不延迟服务可用性,由插件 fiber 拥有,并在 dispose 期间被等待(不会有扫描 I/O 存活至 fiber 之后)。它既不使用周期性定时器,也不运行独立进程,因此长期运行的部署要到下次重启才会再次扫描。 -扫描会检查配置的 `root` **以及**先前使用默认根目录的运行在操作系统临时目录下留下的所有 `dsh-spill-*` 临时根目录。在每个根目录中,扫描会删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件;它只修剪发现的先前默认根目录中的空会话目录和空根目录,而活动根目录会保留其会话目录,以避免与写入操作竞争。如果另一个进程修剪了一个仍在使用的发现根目录,写入操作会重新创建其会话目录。扫描绝不会跟随或删除符号链接,会跳过无关条目,并兜底每一次文件系统或警告接收方失败,因此无法使激活或并发 spill 写入失败。保留是刻意的:旧的模型可见定位信息只有超过截止时间后才会失效。 +扫描会检查配置的 `root` **以及**先前使用默认根目录的运行在操作系统临时目录下留下的所有 `dsh-spill-*` 临时根目录。它会把每个根目录解析为文件系统身份,因此当配置路径是某个已发现根目录的别名时,该目录仍会作为不可删除的活动根目录处理。在每个根目录中,扫描会删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件并修剪所有空会话目录;只有发现的先前默认根目录会在变空后被删除。如果清理与写入发生竞争,写入操作会重新创建会话目录。扫描绝不会跟随或删除符号链接,并会跳过无关条目。 + +在 POSIX 上,清理只接受由当前用户拥有、组用户和其他用户不可写、且祖先路径可防止他人替换的根目录;`/tmp` 这类带 sticky 位的可写临时目录仍然允许使用。会话目录必须满足相同的所有权和写权限限制。不安全路径会被跳过并记录警告,从而防止不受信任的本地进程把基于路径的删除重定向到 spill 根目录之外。所有文件系统故障和警告接收方故障都会被兜底,因此清理无法使激活或并发 spill 写入失败。保留是刻意的:旧的模型可见定位信息只有超过截止时间后才会失效。 `saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md),扫描机制见[启动清理 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md)。 diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 44ca42effd..86eb53c585 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -40,6 +40,8 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/spill/spill-local/src/cleanup.ts b/packages/spill/spill-local/src/cleanup.ts index c138f68a84..2de7198d96 100644 --- a/packages/spill/spill-local/src/cleanup.ts +++ b/packages/spill/spill-local/src/cleanup.ts @@ -1,6 +1,7 @@ /** Startup cleanup mechanics for local spill roots. */ -import { lstat, readdir, rmdir, unlink } from 'node:fs/promises' -import { join } from 'node:path' +import { lstat, readdir, realpath, rmdir, unlink } from 'node:fs/promises' +import type { Stats } from 'node:fs' +import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { DEFAULT_ROOT_PREFIX, isErrno } from './store.ts' @@ -21,6 +22,14 @@ const DEFAULT_ROOT_RE = new RegExp(`^${DEFAULT_ROOT_PREFIX}[A-Za-z0-9]{6}$`) */ const SESSION_DIR_RE = /^session-[0-9a-f]{12}$/ +/** An existing root resolved to one stable filesystem identity. */ +interface ResolvedRoot { + /** Canonical absolute path used for the sweep. */ + path: string + /** Device/inode identity used to de-duplicate filesystem aliases. */ + identity: string +} + /** A one-argument warning sink — the sweep's only side effect on failure (never throws). */ export type WarnFn = (message: string) => void @@ -34,16 +43,116 @@ function warnSafely(warn: WarnFn, message: string): void { } } -/** One root to sweep, plus whether its empty session directories and root may be pruned. */ +/** Whether another local OS user cannot replace children of this directory. */ +function isTrustedDirectory(stats: Stats): boolean { + if (!stats.isDirectory()) return false + /* v8 ignore next -- POSIX ownership and mode bits have no Windows equivalent. */ + if (process.platform === 'win32' || process.geteuid === undefined) return true + return stats.uid === process.geteuid() && (stats.mode & 0o022) === 0 +} + +/** Stable identity for de-duplicating aliases of one root. */ +function rootIdentity(path: string, stats: Stats): string { + /* v8 ignore next -- Windows file indexes are not portable inode identities. */ + if (process.platform === 'win32') return path.toLowerCase() + return `${String(stats.dev)}:${String(stats.ino)}` +} + +/** + * Check that no ancestor permits another local OS user to replace the selected + * child. A sticky writable ancestor is safe because the child is owned by the + * current user; this admits normal per-process roots below `/tmp`. + */ +async function hasProtectedAncestors(path: string): Promise { + /* v8 ignore next -- POSIX ancestry checks have no Windows ACL equivalent. */ + if (process.platform === 'win32' || process.geteuid === undefined) return true + const currentUid = process.geteuid() + let child = path + let childStats = await lstat(child) + for (;;) { + const parent = dirname(child) + if (parent === child) return true + const stats = await lstat(parent) + /* v8 ignore next -- every ancestor of a successfully resolved path is a directory. */ + if (!stats.isDirectory()) return false + const writableByOthers = (stats.mode & 0o022) !== 0 + const sticky = (stats.mode & 0o1000) !== 0 + if (writableByOthers && !sticky) return false + /* v8 ignore next -- requires an ancestor owned by another OS account inside + a writable sticky parent; ordinary test fixtures cannot change uid. */ + if (writableByOthers && childStats.uid !== currentUid) return false + child = parent + childStats = stats + } +} + +/** + * Resolve one existing root without admitting a directory another local user + * can replace during the path-based sweep. A configured root may be a symlink; + * discovery passes `false` so a symlink cannot impersonate a default root. + * + * @param path Candidate root path. + * @param allowSymlink Whether the candidate itself may be a configured symlink. + * @param warn Sink for skipped or failed inspection. + * @returns The trusted canonical root, or `undefined` when it is absent or unsafe. + */ +async function resolveRoot(path: string, allowSymlink: boolean, warn: WarnFn): Promise { + let initial: Stats + try { + initial = await lstat(path) + } catch (error: unknown) { + /* v8 ignore start -- non-ENOENT inspection failures depend on host ACL or + an entry racing away and cannot be reproduced portably. */ + if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to inspect root ${path}: ${String(error)}`) + return undefined + /* v8 ignore stop */ + } + if (initial.isSymbolicLink()) { + if (!allowSymlink) return undefined + } else if (!isTrustedDirectory(initial)) { + warnSafely(warn, `spill-local: skipped unsafe root ${path}: expected a directory owned by the current user and not writable by group or others`) + return undefined + } + + let canonical: string + let stats: Stats + try { + canonical = await realpath(path) + stats = await lstat(canonical) + } catch (error: unknown) { + /* v8 ignore start -- a root lstat'd above reaches this only by racing away + or by a host-specific realpath failure. */ + if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to resolve root ${path}: ${String(error)}`) + return undefined + /* v8 ignore stop */ + } + let protectedAncestors = false + try { + protectedAncestors = await hasProtectedAncestors(canonical) + } catch (error: unknown) { + /* v8 ignore start -- a canonical ancestor disappears only through a race; + other failures depend on host ACLs. */ + if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to inspect ancestors of root ${canonical}: ${String(error)}`) + return undefined + /* v8 ignore stop */ + } + if (!isTrustedDirectory(stats) || !protectedAncestors) { + warnSafely(warn, `spill-local: skipped unsafe root ${canonical}: expected a current-user-owned directory with protected write and ancestor permissions`) + return undefined + } + return { path: canonical, identity: rootIdentity(canonical, stats) } +} + +/** One root to sweep, plus whether the root itself may be pruned once empty. */ export interface SweepRoot { /** Absolute spill root to sweep. */ path: string /** - * When `true`, prune empty `session-*` children and then remove the root once - * empty. Set for DISCOVERED prior-default `dsh-spill-*` roots (one per past - * process — otherwise they accumulate empty forever), never for the - * active/configured root the live process is still writing into. Writes retry - * if another process still using a discovered root races its pruning. + * When `true`, remove the root after its empty `session-*` children are + * pruned. Set for DISCOVERED prior-default `dsh-spill-*` roots (one per past + * process — otherwise they accumulate empty forever), never for the active + * root the live process is still writing into. Every root prunes empty session + * directories; writes retry if that races their removal. */ pruneWhenEmpty: boolean } @@ -141,26 +250,39 @@ async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Pro /** * Best-effort one-shot cleanup: across each root, delete expired regular files - * under its `session-*` directories, pruning empty directories only in - * discovered prior-default roots. The active root keeps its session directories - * to avoid racing a local write; writes recreate a directory pruned by another - * process. Every filesystem and warning-sink failure is contained, so a caller - * can await this during activation/disposal without it ever rejecting. + * under its `session-*` directories and prune every empty session directory. + * Only a discovered prior-default root is itself removed. Writes recreate a + * session directory when pruning races a local write. Every filesystem and + * warning-sink failure is contained, so a caller can await this during + * activation/disposal without it ever rejecting. * * @param options The roots to sweep, the age cutoff, and the failure sink. * @returns Resolves when the sweep finishes (never rejects). */ export async function sweepSpillRoots(options: SweepOptions): Promise { - const { roots, cutoffMs, warn } = options - for (const root of roots) { + const { cutoffMs, warn } = options + const roots = new Map() + for (const candidate of options.roots) { + const resolved = await resolveRoot(candidate.path, false, warn) + if (resolved === undefined) continue + const existing = roots.get(resolved.identity) + roots.set(resolved.identity, { + path: resolved.path, + pruneWhenEmpty: (existing?.pruneWhenEmpty ?? true) && candidate.pruneWhenEmpty, + }) + } + for (const root of roots.values()) { let entries: string[] try { entries = await readdir(root.path) } catch (error: unknown) { // A root that does not exist yet (no spill ever written) is the common // case, not an error: ENOENT is silent, anything else is reported. + /* v8 ignore start -- the trusted root was resolved immediately above; a + read failure now requires a race or host-specific ACL fault. */ if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to read root ${root.path}: ${String(error)}`) continue + /* v8 ignore stop */ } // Track whether the root holds ANY entry the sweep did not fully reclaim, so // a discovered prior-default root can be pruned only when nothing remains. @@ -185,15 +307,13 @@ export async function sweepSpillRoots(options: SweepOptions): Promise { continue /* v8 ignore stop */ } - if (!stats.isDirectory()) { rootEmptiable = false; continue } - const empty = await sweepSessionDir(dir, cutoffMs, warn) - if (!empty) { rootEmptiable = false; continue } - if (!root.pruneWhenEmpty) { - // The active root remains writable while cleanup runs. Leaving its empty - // session directories in place closes the mkdir/rmdir race with saveText. + if (!isTrustedDirectory(stats)) { + warnSafely(warn, `spill-local: skipped unsafe session directory ${dir}`) rootEmptiable = false continue } + const empty = await sweepSessionDir(dir, cutoffMs, warn) + if (!empty) { rootEmptiable = false; continue } try { await rmdir(dir) } catch (error: unknown) { @@ -210,8 +330,7 @@ export async function sweepSpillRoots(options: SweepOptions): Promise { } // A discovered prior-default root (one per past process) is removed once its // last session dir is gone — otherwise empty roots accumulate forever and - // every future startup rescans them. The active/configured root is never - // pruned (the live process is still writing into it). + // every future startup rescans them. The active root itself is never pruned. if (root.pruneWhenEmpty && rootEmptiable) { try { await rmdir(root.path) @@ -245,7 +364,7 @@ export async function sweepSpillRoots(options: SweepOptions): Promise { * @param base The directory to scan; defaults to the OS tmpdir (a test seam). * @returns Absolute paths of the discovered default roots (possibly empty). */ -export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise { +async function discoverDefaultRootRecords(warn: WarnFn, base: string): Promise { let entries: string[] try { entries = await readdir(base) @@ -253,24 +372,48 @@ export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir() warnSafely(warn, `spill-local: failed to scan ${base} for default roots: ${String(error)}`) return [] } - const roots: string[] = [] + const roots: ResolvedRoot[] = [] for (const name of entries) { if (!DEFAULT_ROOT_RE.test(name)) continue const path = join(base, name) - let stats - try { - // lstat, not stat: a symlink named `dsh-spill-*` must not be treated as a - // root we then sweep (it could point anywhere). - stats = await lstat(path) - } catch (error: unknown) { - /* v8 ignore start -- an entry readdir just returned fails to lstat only by - racing away (ENOENT) or a permission/IO fault; not deterministically - reproducible. */ - if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to stat default root ${path}: ${String(error)}`) - continue - /* v8 ignore stop */ - } - if (stats.isDirectory()) roots.push(path) + const resolved = await resolveRoot(path, false, warn) + if (resolved !== undefined) roots.push(resolved) } return roots } + +/** + * Discover trusted prior default roots below the OS temporary directory. + * + * @param warn Sink for contained discovery failures. + * @param base Directory to scan; defaults to the OS temporary directory. + * @returns Canonical paths of trusted default roots. + */ +export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise { + return (await discoverDefaultRootRecords(warn, base)).map(root => root.path) +} + +/** + * Gather and de-duplicate the trusted roots for one startup sweep. The active + * configured path may be a symlink; its resolved identity overrides a matching + * discovered root so the live target is never marked prunable. + * + * @param activeRoot Active configured root. + * @param warn Sink for contained inspection failures. + * @param defaultRootsBase Directory holding prior default roots. + * @returns Trusted roots with the active identity marked non-prunable. + */ +export async function gatherSweepRoots( + activeRoot: string, + warn: WarnFn, + defaultRootsBase: string = tmpdir(), +): Promise { + const [discovered, active] = await Promise.all([ + discoverDefaultRootRecords(warn, defaultRootsBase), + resolveRoot(activeRoot, true, warn), + ]) + const roots = new Map() + for (const root of discovered) roots.set(root.identity, { path: root.path, pruneWhenEmpty: true }) + if (active !== undefined) roots.set(active.identity, { path: active.path, pruneWhenEmpty: false }) + return [...roots.values()] +} diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts index f767a1a12d..5280ebdae0 100644 --- a/packages/spill/spill-local/src/index.ts +++ b/packages/spill/spill-local/src/index.ts @@ -15,7 +15,7 @@ import { tmpdir } from 'node:os' import z from '@deepseek-ai/schemastery' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' -import { discoverDefaultRoots, sweepSpillRoots } from './cleanup.ts' +import { gatherSweepRoots, sweepSpillRoots } from './cleanup.ts' import type { SweepRoot, WarnFn } from './cleanup.ts' import { privateRoot, saveTextFile } from './store.ts' @@ -40,8 +40,10 @@ export interface Config { * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose * `mtime` is strictly older than the cutoff are deleted and emptied * directories are pruned; fresh files, symlinks, and unrelated entries are - * left untouched. Retention is deliberate — a resumed or forked session may - * still reference an older locator until it ages out. + * left untouched. On POSIX, cleanup skips roots and session directories that + * another local user could modify or replace. Retention is deliberate — a + * resumed or forked session may still reference an older locator until it + * ages out. */ cleanupPeriodDays?: number } @@ -63,7 +65,7 @@ type ResolvedConfig = Required> & Pick export class LocalSpillStore extends SpillStore { static Config: z = z.object({ root: z.string(), - cleanupPeriodDays: z.number().default(30), + cleanupPeriodDays: z.number().step(1).min(0).default(30), }) /** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */ @@ -83,9 +85,6 @@ export class LocalSpillStore extends SpillStore { // schemastery (static Config) has already filled `cleanupPeriodDays`; the // cast records that runtime fact for exactOptionalPropertyTypes. this.config = config as ResolvedConfig - if (!Number.isInteger(this.config.cleanupPeriodDays) || this.config.cleanupPeriodDays < 0) { - throw new Error(`spill-local: cleanupPeriodDays must be a non-negative integer (got ${this.config.cleanupPeriodDays})`) - } this.root = config.root !== undefined ? resolve(config.root) : privateRoot() // One best-effort startup sweep, owned by the fiber. The generator body runs @@ -120,24 +119,19 @@ export class LocalSpillStore extends SpillStore { /** * The roots the startup sweep covers: each discovered prior-default * `dsh-spill-*` temp root (see {@link discoverDefaultRoots}), pruned when - * emptied, plus the active/configured root, whose root and session directories - * are NEVER pruned (the live process is still writing into them). The active - * root is de-duped out of the discovered set so it is not swept twice or - * marked prunable. A test - * overrides this to inject an isolated root set — and, being the sweep's one - * async gather point, to hold the sweep open across a disposal for the - * quiescence check; it is a test seam, not a deployment knob. + * emptied, plus the active/configured root, which is never itself pruned while + * the live process may write into it. Empty session directories are pruned in + * every root. Filesystem identity de-duplicates aliases before the active root + * overrides a discovered match as non-prunable. A test overrides this to + * inject an isolated root set — and, being the sweep's one async gather point, + * to hold the sweep open across a disposal for the quiescence check; it is a + * test seam, not a deployment knob. * * @param warn - sink for a contained discovery failure. * @returns The roots to sweep, each flagged for prune-when-empty. */ protected async gatherRoots(warn: WarnFn): Promise { - const discovered = await discoverDefaultRoots(warn, this.defaultRootsBase()) - const roots: SweepRoot[] = discovered - .filter(path => path !== this.root) - .map(path => ({ path, pruneWhenEmpty: true })) - roots.push({ path: this.root, pruneWhenEmpty: false }) - return roots + return gatherSweepRoots(this.root, warn, this.defaultRootsBase()) } /** diff --git a/packages/spill/spill-local/tests/loader-composition.spec.ts b/packages/spill/spill-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..cf634fbc38 --- /dev/null +++ b/packages/spill/spill-local/tests/loader-composition.spec.ts @@ -0,0 +1,78 @@ +/** + * Real-composition proof: a cordis.yml loaded by the vendored Loader applies + * spill-local configuration and completes its fiber-owned startup cleanup. + */ + +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import LocalSpillStore, { sessionDir } from '@deepseek-ai/dsh-spill-local' + +const DAY_MS = 24 * 60 * 60 * 1000 + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('spill-local real Loader composition through cordis.yml', () => { + it('loads cleanupPeriodDays and prunes only expired session contents', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-spill-loader-')) + const oldDir = sessionDir(root, 'old-session') + const freshDir = sessionDir(root, 'fresh-session') + await mkdir(oldDir, { recursive: true }) + await mkdir(freshDir, { recursive: true }) + const old = join(oldDir, 'old.txt') + const fresh = join(freshDir, 'fresh.txt') + await writeFile(old, 'old') + await writeFile(fresh, 'fresh') + const now = Date.now() + await utimes(old, (now - 40 * DAY_MS) / 1000, (now - 40 * DAY_MS) / 1000) + await utimes(fresh, (now - DAY_MS) / 1000, (now - DAY_MS) / 1000) + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-spill-local'", + ' config:', + ` root: ${JSON.stringify(root)}`, + ' cleanupPeriodDays: 30', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier !== '@deepseek-ai/dsh-spill-local') throw new Error(`unexpected Loader import: ${specifier}`) + return LocalSpillStore + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + await context.fiber.dispose() + context = undefined + + expect(existsSync(old)).toBe(false) + expect(existsSync(oldDir)).toBe(false) + expect(existsSync(fresh)).toBe(true) + expect(existsSync(freshDir)).toBe(true) + expect(existsSync(root)).toBe(true) + }, 30_000) +}) diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index 8bdb5a7471..329f1d9148 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -11,7 +11,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, normalize } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' @@ -28,6 +28,7 @@ import LocalSpillStore, { sweepSpillRoots, } from '@deepseek-ai/dsh-spill-local' import type { SweepRoot } from '@deepseek-ai/dsh-spill-local' +import { gatherSweepRoots } from '../src/cleanup.ts' const DAY_MS = 24 * 60 * 60 * 1000 @@ -169,9 +170,9 @@ describe('LocalSpillStore service', () => { it('rejects a negative or fractional cleanupPeriodDays at load', async () => { await expect(new Context().plugin(LocalSpillStore, { root, cleanupPeriodDays: -1 })) - .rejects.toThrow(/cleanupPeriodDays must be a non-negative integer/) + .rejects.toThrow() await expect(new Context().plugin(LocalSpillStore, { root, cleanupPeriodDays: 1.5 })) - .rejects.toThrow(/cleanupPeriodDays must be a non-negative integer/) + .rejects.toThrow() }) it('defaults cleanupPeriodDays to 30', async () => { @@ -207,8 +208,8 @@ describe('LocalSpillStore service', () => { }) it('routes a sweep filesystem failure to ctx.logger.warn (service warn wiring)', async () => { - // A root that is a FILE, not a directory, makes readdir throw ENOTDIR inside - // the real sweep. The service's warn closure must forward it to + // A root that is a FILE, not a directory, is rejected by the real sweep. + // The service's warn closure must forward that failure to // ctx.logger.warn, and disposal must still settle cleanly. const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x') const ctx = new Context() @@ -218,7 +219,7 @@ describe('LocalSpillStore service', () => { } const fiber = await ctx.plugin(Discovering, { root: filePath, cleanupPeriodDays: 30 }) await fiber.dispose() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unsafe root')) }) }) @@ -265,10 +266,11 @@ describe('startup cleanup sweep', () => { it('keeps a file exactly at the boundary (only strictly-older expires)', async () => { const dir = sessionDir(root, 'sess-1') mkdirSync(dir, { recursive: true }) - // mtime == cutoff: mtimeMs >= cutoffMs holds, so it is kept. Age it just - // under 30d to avoid the sub-millisecond race of "exactly now - 30d". - const boundary = join(dir, 'boundary.txt'); writeAged(boundary, 'x', 29.9) - await runSweep([active(root)]) + const cutoffMs = Date.now() - 30 * DAY_MS + const boundary = join(dir, 'boundary.txt') + writeFileSync(boundary, 'x') + utimesSync(boundary, cutoffMs / 1000, cutoffMs / 1000) + await sweepSpillRoots({ roots: [active(root)], cutoffMs, warn: () => {} }) expect(existsSync(boundary)).toBe(true) }) @@ -280,7 +282,7 @@ describe('startup cleanup sweep', () => { expect(existsSync(old)).toBe(true) }) - it('keeps active session directories after deleting expired files', async () => { + it('prunes empty active session directories after deleting expired files', async () => { const emptied = sessionDir(root, 'emptied') const kept = sessionDir(root, 'kept') mkdirSync(emptied, { recursive: true }) @@ -288,7 +290,7 @@ describe('startup cleanup sweep', () => { writeAged(join(emptied, 'a.txt'), 'x', 40) writeAged(join(kept, 'fresh.txt'), 'y', 1) await runSweep([active(root)]) - expect(existsSync(emptied)).toBe(true) + expect(existsSync(emptied)).toBe(false) expect(existsSync(kept)).toBe(true) }) @@ -322,6 +324,18 @@ describe('startup cleanup sweep', () => { expect(existsSync(link)).toBe(true) }) + it('skips a POSIX session directory writable by another local user', async () => { + if (process.platform === 'win32') return + const dir = sessionDir(root, 'sess-1') + mkdirSync(dir, { recursive: true }) + const old = join(dir, 'old.txt'); writeAged(old, 'x', 40) + chmodSync(dir, 0o777) + const warn = vi.fn() + await sweepSpillRoots({ roots: [active(root)], cutoffMs: Date.now(), warn }) + expect(existsSync(old)).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unsafe session directory')) + }) + it('sweeps only exact session-<12hex> names, not lookalikes', async () => { // `session-backup` and `session-<11hex>` match the old startsWith check but // are NOT backend-generated names; their old files must survive. @@ -351,12 +365,29 @@ describe('startup cleanup sweep', () => { await runSweep([{ path: prior, pruneWhenEmpty: true }, active(root)]) expect(existsSync(prior)).toBe(false) // discovered root pruned expect(existsSync(root)).toBe(true) // active root kept - expect(existsSync(activeDir)).toBe(true) // active session dirs remain writable + expect(existsSync(activeDir)).toBe(false) // empty active session dirs are pruned } finally { rmSync(prior, { recursive: true, force: true }) } }) + it('de-duplicates repeated roots and lets non-prunable status win', async () => { + const dir = sessionDir(root, 'sess-1') + mkdirSync(dir, { recursive: true }) + writeAged(join(dir, 'old.txt'), 'x', 40) + await sweepSpillRoots({ + roots: [ + { path: root, pruneWhenEmpty: true }, + { path: root, pruneWhenEmpty: false }, + { path: root, pruneWhenEmpty: true }, + ], + cutoffMs: Date.now() - 30 * DAY_MS, + warn: () => {}, + }) + expect(existsSync(dir)).toBe(false) + expect(existsSync(root)).toBe(true) + }) + it('does NOT prune a discovered root that still holds a fresh file', async () => { const prior = mkdtempSync(join(tmpdir(), 'dsh-spill-')) const priorDir = sessionDir(prior, 'sess'); mkdirSync(priorDir, { recursive: true }) @@ -424,6 +455,43 @@ describe('startup cleanup sweep', () => { } }) + it('de-dups a configured symlink alias by filesystem identity and keeps its target writable', async () => { + const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-')) + const activeDefault = mkdtempSync(join(fakeTmp, DEFAULT_ROOT_PREFIX)) + const alias = join(root, 'configured-root') + symlinkSync(activeDefault, alias, process.platform === 'win32' ? 'junction' : 'dir') + const dir = sessionDir(activeDefault, 'sess-1') + mkdirSync(dir, { recursive: true }) + const old = join(dir, 'old.txt'); writeAged(old, 'x', 40) + try { + const roots = await gatherSweepRoots(alias, () => {}, fakeTmp) + expect(roots).toEqual([{ path: realpathSync(activeDefault), pruneWhenEmpty: false }]) + await sweepSpillRoots({ roots, cutoffMs: Date.now() - 30 * DAY_MS, warn: () => {} }) + expect(existsSync(old)).toBe(false) + expect(existsSync(activeDefault)).toBe(true) + const saved = await saveTextFile({ root: alias, sessionId: 'next', suggestedName: 'ok.txt', content: 'ok' }) + expect(readFileSync(saved.path, 'utf8')).toBe('ok') + } finally { + rmSync(fakeTmp, { recursive: true, force: true }) + } + }) + + it('skips a root that another POSIX user could replace', async () => { + if (process.platform === 'win32') return + const unsafeParent = join(root, 'unsafe-parent') + const unsafeRoot = join(unsafeParent, 'configured') + mkdirSync(unsafeRoot, { recursive: true, mode: 0o700 }) + const dir = sessionDir(unsafeRoot, 'sess-1') + mkdirSync(dir, { recursive: true }) + const old = join(dir, 'old.txt'); writeAged(old, 'x', 40) + chmodSync(unsafeParent, 0o777) + const warn = vi.fn() + const roots = await gatherSweepRoots(unsafeRoot, warn, join(root, 'missing-discovery-base')) + expect(roots).toEqual([]) + expect(existsSync(old)).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unsafe root')) + }) + it('does not block activation but is awaited on disposal (quiescence)', async () => { const dir = sessionDir(root, 'sess-1') mkdirSync(dir, { recursive: true }) @@ -449,13 +517,13 @@ describe('startup cleanup sweep', () => { expect(existsSync(old)).toBe(false) }) - it('a filesystem failure is contained (logged, never thrown) and does not fail a spill write', async () => { + it('an unsafe root is contained (logged, never thrown)', async () => { const warn = vi.fn() - // A path that is a FILE, not a directory: readdir(root) throws ENOTDIR. The + // A path that is a FILE, not a directory, is not a valid cleanup root. The // sweep must log and return, never reject. const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x') await expect(sweepSpillRoots({ roots: [active(filePath)], cutoffMs: Date.now(), warn })).resolves.toBeUndefined() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unsafe root')) }) it('contains an exception from the warning sink', async () => { @@ -484,7 +552,7 @@ describe('discoverDefaultRoots', () => { writeFileSync(join(base, `${DEFAULT_ROOT_PREFIX}file01`), 'x') // matches shape but is a file symlinkSync(realRoot, join(base, `${DEFAULT_ROOT_PREFIX}link01`)) // matches shape but is a symlink const found = await discoverDefaultRoots(() => {}, base) - expect(found).toEqual([realRoot]) + expect(found).toEqual([realpathSync(realRoot)]) } finally { rmSync(base, { recursive: true, force: true }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e763188fd0..639117a6a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7978,6 +7978,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand From 30704dc1df10a54c31d0ad6513bcc2690feaf9de Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 17:10:59 +0800 Subject: [PATCH 056/138] fix(attachment): budget master pixels and share the encoding ladder Master dimensions move from a 2048 long-edge rule to a total-pixel budget (normalizedImageMaxPixels, default 2048x2048) with an 8192 long-edge cap, so extreme aspect ratios keep short-edge resolution. The shared quality ladder and lazy execution move to encoding.ts, review-round doc fixes land across attachment and llm packages, and the superseded facts in the unified-image-pipeline note now describe the shipped routing. --- ...pha-routed-image-quality-ladders.i18n.yaml | 4 +- ...8-24-alpha-routed-image-quality-ladders.md | 8 ++- ...4-alpha-routed-image-quality-ladders.zh.md | 8 ++- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 10 ++-- ...08-20-unified-image-request-pipeline.zh.md | 10 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 6 +- docs/config-catalog.zh.md | 6 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 2 +- docs/subsystems/attachment.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 4 +- .../attachment/attachment-local/README.zh.md | 4 +- .../attachment-local/src/encoding.ts | 43 +++++++++++++- .../attachment/attachment-local/src/index.ts | 18 ++++-- .../attachment-local/src/normalization.ts | 59 ++++++------------- .../attachment-local/src/request-image.ts | 36 +++-------- .../attachment-local/tests/index.spec.ts | 2 + .../tests/normalization-verification.spec.ts | 2 +- .../tests/normalization.spec.ts | 52 ++++++++++++---- .../attachment-local/tests/store.spec.ts | 4 +- packages/attachment/attachment/src/index.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/config.ts | 4 +- 30 files changed, 176 insertions(+), 138 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml index 344499741c..e2e60901af 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.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-24-alpha-routed-image-quality-ladders.md -2026-08-24-alpha-routed-image-quality-ladders.md: 16b3213268999d846f0b2a5ab63b3f5ae4adc332 -2026-08-24-alpha-routed-image-quality-ladders.zh.md: dde9ba60996cdfb779a6a1a88810fec608bc0609 +2026-08-24-alpha-routed-image-quality-ladders.md: cdf5e7bd908782781e66a4f6494428f0f174e258 +2026-08-24-alpha-routed-image-quality-ladders.zh.md: fbe8215b0ca1c606cd66d60fe1109d2f9aa1cbad diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md index 16b3213268..cdf5e7bd90 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md @@ -10,15 +10,17 @@ Image normalization and request-image encoding in `@deepseek-ai/dsh-attachment-l ## Decision -Both encoders route by one decoded fact only: sources with an alpha channel encode as lossy WebP at effort 0, opaque sources as JPEG (libjpeg-turbo), each down a shared quality ladder of 85, 75, 60 (`IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` in `normalization.ts`). The colour-count classifier and the palette PNG branch are deleted, not repaired, so the misclassification bug class cannot recur and no image pays the classification decode. `normalizedImageMaxBytes` and the route `maxBytes` become ladder targets rather than caps: the ladder still stops at the first quality that fits, but when every quality exceeds the target the smallest output is kept and the downscale retry loop is gone. Provider byte limits (DeepSeek 32MiB per image, inline budgets) remain enforced where the bytes are transmitted. The request transform version moves to `request-image-v5`, so existing cached variants regenerate by identity; content-addressed masters stay valid without migration. The request cache read no longer rejects entries above the byte target, since a ladder-exhausted output is the deterministic result for its variant id. +Both encoders route by one decoded fact only: sources with an alpha channel encode as lossy WebP at effort 0, opaque sources as JPEG (libjpeg-turbo), each down a shared quality ladder of 85, 75, 60 (`IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` / `encodingLadder` in `encoding.ts`). The colour-count classifier and the palette PNG branch are deleted, not repaired, so the misclassification bug class cannot recur and no image pays the classification decode. `normalizedImageMaxBytes` and the route `maxBytes` become ladder targets rather than caps: the ladder still stops at the first quality that fits, but when every quality exceeds the target the smallest output is kept and the downscale retry loop is gone. Provider byte limits (DeepSeek 32MiB per image, inline budgets) remain enforced where the bytes are transmitted. Master dimensions move from a long-edge rule to a total-pixel budget: `normalizedImageMaxPixels` (default 2048x2048) scales the raster proportionally and `normalizedImageMaxDimension` (default 8192, matching the admission per-side cap) clamps the long edge afterwards, so extreme aspect ratios such as tall page screenshots keep their short-edge resolution (a 2000x20000 source keeps about 647px of width instead of 204px) while square sources normalize exactly as before. The request transform version moves to `request-image-v5`, so existing cached variants regenerate by identity; content-addressed masters stay valid without migration. The request cache read no longer rejects entries above the byte target, since a ladder-exhausted output is the deterministic result for its variant id. Pareto measurements over the issue #2885 reproduction set (PR #2989 appendices) back the choice: on photographic content JPEG is one to two orders of magnitude faster than every alternative, and WebP at effort 0 matches palette PNG's size on graphics content while never being misrouted; uniform-noise worst cases fit the default 4MiB/1MiB targets at quality 85 for opaque sources, and only an adversarial random-alpha plane exhausts the WebP ladder (about 6.3MiB, five times under the provider cap). +This decision partially supersedes the [unified image request pipeline note](../feature/2026-08-20-unified-image-request-pipeline.md), whose normalization and request-encoding sections now describe this routing; its durable-version split, Files lifecycle, and offload projection stand unchanged. + ## Alternatives considered **Repair the classifier (higher-resolution sampling, gradient statistics) and keep palette PNG.** Rejected: any content classifier retains a misrouting class and the per-image classification decode; palette PNG's only frontier niche (graphics) is matched by WebP at a fraction of the encode time. -**A single WebP ladder for everything.** Rejected in review discussion: JPEG is four to six times faster on opaque photographic content, the dominant real workload, and the alpha probe is a metadata read costing nothing. +**A single WebP ladder for everything.** Rejected: JPEG is four to six times faster on opaque photographic content, the dominant real workload, and the alpha probe is a metadata read costing nothing. **Keep the downscale retry loop for ladder-exhausted outputs.** Rejected: measured worst cases show the loop is dead code within default budgets, and its only reachable effect was degrading adversarial inputs to 1×1 before erroring. @@ -27,5 +29,5 @@ Pareto measurements over the issue #2885 reproduction set (PR #2989 appendices) - Opaque low-colour graphics (charts, text screenshots) now store as JPEG: two to three times larger than palette PNG in the hundreds-of-kilobytes range, with JPEG ringing on hard edges; the model-visible request version was already dominated by pixel-budget downscaling, so legibility impact is marginal. Reintroducing a graphics codec would add a WebP step to the opaque ladder, not restore classification. - GIF sources decode with an alpha plane under gifload, so still-frame GIFs normalize onto the WebP ladder. - `IMAGE_TOO_LARGE` no longer arises from encoding; it remains the admission error for oversized sources. -- A ladder-exhausted attachment can exceed its byte target on disk and on the wire until a provider cap rejects it; measured reachable only with adversarial random-alpha input. +- A ladder-exhausted attachment can exceed its byte target on disk and on the wire until a provider cap rejects it; measured reachable only with adversarial random-alpha input. Re-submitting such an over-target master as a new upload fails the pass-through byte check and re-encodes it down the lossy ladder again, so normalization is not idempotent for this adversarial-only class and each round adds generation loss. - Test evidence: `packages/attachment/attachment-local/tests` pins the routing, ladder-exhaustion, and readable-text behavior against real encoders, including the issue #2885 misrouting characteristics (high-frequency photographic content leaving the slow path). diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md index dde9ba6099..fbe8215b0c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md @@ -10,15 +10,17 @@ Status: implemented ## 决定 -两个编码器只按一个解码事实路由:带 alpha 通道的源图编码为 effort 0 的有损 WebP,不透明源图编码为 JPEG(libjpeg-turbo),共用质量阶梯 85、75、60(`normalization.ts` 的 `IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT`)。色数分类器和 palette PNG 分支被删除而不是修复,误判这一 bug 类别因此不可能复发,也不再有图片付出分类解码成本。`normalizedImageMaxBytes` 和路由 `maxBytes` 的语义从上限改为阶梯目标:阶梯仍在第一个装得下的质量档停下,但全部档位都超过目标时保留最小产物,缩图重试循环被删除。提供方字节硬限制(DeepSeek 单图 32MiB、inline 预算)仍在传输字节的位置执行。请求变换版本升到 `request-image-v5`,已有变体缓存按身份自然重建;内容寻址的 master 无需迁移,继续有效。请求缓存读取不再拒绝超过字节目标的条目,因为阶梯耗尽的产物就是该 variant id 的确定性结果。 +两个编码器只按一个解码事实路由:带 alpha 通道的源图编码为 effort 0 的有损 WebP,不透明源图编码为 JPEG(libjpeg-turbo),共用质量阶梯 85、75、60(`encoding.ts` 的 `IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` / `encodingLadder`)。色数分类器和 palette PNG 分支被删除而不是修复,误判这一 bug 类别因此不可能复发,也不再有图片付出分类解码成本。`normalizedImageMaxBytes` 和路由 `maxBytes` 的语义从上限改为阶梯目标:阶梯仍在第一个装得下的质量档停下,但全部档位都超过目标时保留最小产物,缩图重试循环被删除。提供方字节硬限制(DeepSeek 单图 32MiB、inline 预算)仍在传输字节的位置执行。master 尺寸规则从长边上限改为总像素预算:`normalizedImageMaxPixels`(默认 2048×2048)按比例缩放,`normalizedImageMaxDimension`(默认 8192,与准入单边上限一致)随后夹住长边,因此长页面截图这类极端长宽比保留短边分辨率(2000×20000 的源图短边保留约 647px 而不是 204px),正方形源图的规范化结果与之前完全一致。请求变换版本升到 `request-image-v5`,已有变体缓存按身份自然重建;内容寻址的 master 无需迁移,继续有效。请求缓存读取不再拒绝超过字节目标的条目,因为阶梯耗尽的产物就是该 variant id 的确定性结果。 对 issue #2885 复现集的 Pareto 实测(PR #2989 附录)支撑这个选择:摄影类内容上 JPEG 比其余所有编码器快 1 至 2 个数量级,effort 0 的 WebP 在图形类内容上体积与 palette PNG 相当且不会被误判;均匀噪声最坏输入在不透明链的 q85 一档即落入默认 4MiB/1MiB 目标,只有对抗性的随机 alpha 平面会耗尽 WebP 阶梯(约 6.3MiB,距提供方上限还有 5 倍)。 +本决定部分取代[统一图片请求管线记录](../feature/2026-08-20-unified-image-request-pipeline.zh.md):其规范化与请求编码章节现在以本路由为准;其耐久版本拆分、Files 生命周期与卸载投影不变。 + ## 考虑过的替代方案 **修复分类器(提高采样分辨率、加入梯度统计)并保留 palette PNG。** 否决:任何内容分类器都保留一类误判和每张图的分类解码成本;palette PNG 唯一的前沿生态位(图形类)WebP 用远少的编码时间即可达到。 -**全部走单一 WebP 阶梯。** 在评审讨论中否决:JPEG 在不透明摄影内容(真实负载的大头)上快 4 至 6 倍,而 alpha 探测只是零成本的元数据读取。 +**全部走单一 WebP 阶梯。** 否决:JPEG 在不透明摄影内容(真实负载的大头)上快 4 至 6 倍,而 alpha 探测只是零成本的元数据读取。 **为阶梯耗尽的产物保留缩图重试循环。** 否决:实测最坏情况表明该循环在默认预算内是死代码,其唯一可达效果是把对抗性输入一路缩到 1×1 再报错。 @@ -27,5 +29,5 @@ Status: implemented - 不透明的低色数图形(图表、文字截图)现在存为 JPEG:在几百 KB 量级上比 palette PNG 大 2 至 3 倍,锐利边缘有 JPEG 振铃;模型可见的请求版本本就被像素预算缩尺寸主导,可读性影响很小。将来若需要图形类专用编码,正确做法是给不透明阶梯加一档 WebP,而不是恢复分类。 - GIF 源图经 gifload 解码后带 alpha 平面,因此静帧 GIF 规范化走 WebP 阶梯。 - `IMAGE_TOO_LARGE` 不再产生于编码环节;它仍是超大源图的准入错误。 -- 阶梯耗尽的附件可能以超过字节目标的大小落盘和上行,直到提供方上限拒绝;实测只有对抗性随机 alpha 输入可达。 +- 阶梯耗尽的附件可能以超过字节目标的大小落盘和上行,直到提供方上限拒绝;实测只有对抗性随机 alpha 输入可达。把这样的超目标 master 再次作为新上传提交时,直通的字节检查不通过,会再走一遍有损阶梯,因此规范化对这一仅对抗性可达的类别不幂等,每轮都会累积代际损失。 - 测试证据:`packages/attachment/attachment-local/tests` 用真实编码器钉住路由、阶梯耗尽和文字可读性行为,包括 issue #2885 误判特征(高频摄影内容离开慢路径)。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 66ba6d1139..55331d4816 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 85296f1d8bd7d6ee458f8bc4230e52f1d3128bff -2026-08-20-unified-image-request-pipeline.zh.md: bcbc0110001e9f974e57a456140e9ecdf4eb888c +2026-08-20-unified-image-request-pipeline.md: 1c7a8040232bab0d73f578a9170e235c2f175c07 +2026-08-20-unified-image-request-pipeline.zh.md: af6497f5a06018b87d166842615f6b1e12b1aed4 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 85296f1d8b..1c7a804023 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -14,17 +14,17 @@ The image path has two explicit versions. The attachment backend owns a provider ### Provider-independent normalized attachment -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `normalizedImageMaxDimension`, 2048px by default. When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while scaling into the `normalizedImageMaxPixels` total-pixel budget (2048x2048 by default) under a `normalizedImageMaxDimension` long-edge cap (8192px by default). When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization. -The normalized attachment has an independent `normalizedImageMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both normalization limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. +The normalized attachment has an independent `normalizedImageMaxBytes` encoded-byte target, 4MiB by default. Alpha is never flattened. Codec routing is by the decoded alpha fact alone — alpha input encodes as WebP (effort 0) and opaque input as JPEG, each down the shared 85/75/60 quality ladder; when every quality exceeds the target the smallest output is kept, per the superseding [alpha-routed quality ladders note](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md). A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within every normalization limit passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. Batch admission prepares and verifies every normalized attachment once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule. ### Deterministic request versions -`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and a 1MiB raw encoded-byte target by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same alpha routing and 85/75/60 quality ladder as normalization, executed lazily; a target no quality meets keeps the smallest ladder output (see the [alpha-routed quality ladders note](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md)). The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. -The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. +The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, and alpha without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references. @@ -62,7 +62,7 @@ Historical attachment objects that later disappear or fail integrity verificatio ## Verification -Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index bcbc011000..af6497f5a0 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -14,17 +14,17 @@ Status: implemented ### 提供方无关的规范化附件 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`,默认 2048px。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比缩放进 `normalizedImageMaxPixels` 总像素预算(默认 2048×2048),随后受 `normalizedImageMaxDimension` 长边上限约束(默认 8192px)。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。 -规范化附件有独立的 `normalizedImageMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 +规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标,默认 4MiB。透明通道绝不铺平。编码路由只看解码出的 alpha 事实:透明输入编码为 WebP(effort 0),非透明输入编码为 JPEG,共用 85/75/60 质量阶梯;全部档位都超过目标时保留最小产物,见取代本节的[按 alpha 路由的质量阶梯记录](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md)。处于全部规范化限制内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 批量准入在发布任何成员前,为每张图片各准备并验证一次规范化附件。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。 ### 确定性请求版本 -`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节目标 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码与规范化共用同一套 alpha 路由和 85/75/60 质量阶梯,按需执行;没有任何档位达到目标时保留最小产物(见[按 alpha 路由的质量阶梯记录](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md))。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 -`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 +`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸和透明通道,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 @@ -62,7 +62,7 @@ Status: implemented ## Verification -包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 ## Consequences diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9c6eb14a09..de34a153cf 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: bbcfbef6dc98f8739c825bca0b2807ca58a1d144 -config-catalog.zh.md: f1db1c5b7d78ee32fd9a0e0d22bcdf8ebb17abb1 +config-catalog.md: 46228907ea4241e69a4439c2dc1c1786de94eaff +config-catalog.zh.md: ed09a3d44f93815ab667ef4613249a33306d9899 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bbcfbef6dc..46228907ea 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -308,7 +308,9 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent normalized image. */ + /** Total-pixel budget of the stored provider-independent normalized image. */ + normalizedImageMaxPixels?: number + /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */ normalizedImageMaxDimension?: number /** * Encoded-byte target of the stored provider-independent normalized image; @@ -320,7 +322,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f1db1c5b7d..ed09a3d44f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -310,7 +310,9 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent normalized image. */ + /** Total-pixel budget of the stored provider-independent normalized image. */ + normalizedImageMaxPixels?: number + /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */ normalizedImageMaxDimension?: number /** * Encoded-byte target of the stored provider-independent normalized image; @@ -322,7 +324,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 195f6acaf3..8e69eaae28 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: aa29c5fc6c011f31fbdb3d4fb9f218d0801e5205 -attachment.zh.md: 9ee90794d28f7cfa0d7b30986aa33d6cbc298f8f +attachment.md: 15daa2b8d541ba847d48f5c43a06c1c537df6d11 +attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index aa29c5fc6c..15daa2b8d5 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -187,7 +187,7 @@ imageHostPath(ref: ImageAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel and encoded-byte budget. + * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 9ee90794d2..c74a18f6be 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -187,7 +187,7 @@ imageHostPath(ref: ImageAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel and encoded-byte budget. + * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 2828e95d6a..353d5d1d7b 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-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/attachment/attachment-local/README.md -README.md: dd779f5e9af7d97d871134296ac95d5536649b22 -README.zh.md: 0c92838c91b2e72eb80b34c0b9780f685642ceed +README.md: 7bd0283e7f922ecfa2be4b67e296f5c0016f4302 +README.zh.md: 6e8ad367cf7e81f373c112e27cace07491638e0f diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index dd779f5e9a..7bd0283e7f 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, a synced temporary file, an atomic exclusive hard-link publish, owner-read-only object permissions, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the raster is reduced proportionally to the `normalizedImageMaxPixels` total-pixel budget (2048x2048 by default) with a `normalizedImageMaxDimension` long-edge cap (8192px by default), so extreme aspect ratios keep their short-edge resolution instead of collapsing under a long-edge rule. The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimension, and alpha facts before use; a mismatch regenerates the entry. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. `imageHostPath` derives the normalized object's absolute host path and does not inspect the tool execution world. At request assembly, an LLM consumer asks the mounted filesystem to map that host object into its execution world. A host-backed filesystem returns a process path; a remote filesystem without a shared mount returns no path. The mapped path is absent from durable history and from `RequestImageAttachment`. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 0c92838c91..6e8ad367cf 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,9 +4,9 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、经过同步的临时文件、原子且排他的硬链接发布、仅所有者可读的对象权限,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把像素总量缩到 `normalizedImageMaxPixels` 总像素预算内(默认 2048×2048),再受 `normalizedImageMaxDimension` 长边上限约束(默认 8192px),因此极端长宽比的图保留短边分辨率,而不会在长边规则下坍缩。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 -请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 +请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前经头部探测校验格式、8-bit sRGB/sRGBA、尺寸和 alpha 事实;不匹配则重新生成该条目。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。`imageHostPath` 派生规范化对象的绝对宿主路径,不检查工具执行环境。组装请求时,LLM 消费方要求当前文件系统把该宿主对象映射到其执行环境。宿主文件系统返回进程路径;没有共享挂载的远程文件系统不返回路径。映射后的路径不进入持久历史,也不进入 `RequestImageAttachment`。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts index bf83d48cf9..1b16aafb30 100644 --- a/packages/attachment/attachment-local/src/encoding.ts +++ b/packages/attachment/attachment-local/src/encoding.ts @@ -1,4 +1,41 @@ -/** Shared lazy candidate execution for normalization and request-image encoders. */ +/** Shared quality ladder and lazy candidate execution for normalization and request-image encoders. */ + +import type { Sharp } from 'sharp' + +/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */ +export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const +/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */ +export const WEBP_ENCODING_EFFORT = 0 + +/** One ladder output carrying its complete bytes and exact facts. */ +export interface EncodedImage { + data: Uint8Array + mediaType: 'image/jpeg' | 'image/webp' + width: number + height: number +} + +async function encode(pipeline: Sharp, mediaType: EncodedImage['mediaType'], quality: number): Promise { + const encoded = mediaType === 'image/webp' + ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT }) + : pipeline.jpeg({ quality }) + const { data, info } = await encoded.toBuffer({ resolveWithObject: true }) + return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } +} + +/** + * Build the lazy quality ladder for one prepared pipeline: WebP keeps a source + * alpha channel, everything else is JPEG. + * @param prepared - sized sRGB pipeline; cloned per candidate. + * @param hasAlpha - decoded source alpha fact selecting the codec. + * @returns encoders ordered from highest to lowest ladder quality. + */ +export function encodingLadder(prepared: Sharp, hasAlpha: boolean): Array<() => Promise> { + const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' + return IMAGE_ENCODING_QUALITIES.map(quality => ( + () => encode(prepared.clone(), mediaType, quality) + )) +} /** One encoded candidate carrying its complete bytes. */ export interface EncodedCandidate { @@ -13,7 +50,7 @@ export interface ExhaustedEncoding { /** * Execute encoding candidates in preference order and stop after the first fitting output. * @param attempts - lazy encoders ordered from preferred to fallback representation. - * @param maxBytes - positive encoded-byte cap. + * @param maxBytes - positive encoded-byte target. * @returns the first fitting candidate, otherwise the smallest completed fallback. */ export async function encodeFirstWithinLimit( @@ -37,7 +74,7 @@ export async function encodeFirstWithinLimit( /** * Whether a lazy encoding result exhausted every candidate at one size. * @param result - first fitting candidate or exhausted result. - * @returns whether every candidate exceeded the byte cap. + * @returns whether every candidate exceeded the byte target. */ export function isExhaustedEncoding( result: T | ExhaustedEncoding, diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 59467107a8..46919200eb 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -35,11 +35,15 @@ export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000 /** Default per-side pixel cap for one submitted image. */ export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 /** - * Default long-edge target of the stored normalized image. A larger source - * is admitted and downscaled to this edge, so admission bounds what rides - * every later model request without refusing ordinary large sources. + * Default total-pixel budget of the stored normalized image. A larger source + * is admitted and downscaled proportionally, so admission bounds what rides + * every later model request without refusing ordinary large sources; extreme + * aspect ratios keep their short-edge resolution instead of collapsing under + * a long-edge rule. */ -export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 2048 +export const DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS = 2048 * 2048 +/** Default long-edge cap of the stored normalized image, applied after the total-pixel budget. */ +export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 8192 /** Default encoded-byte target for one stored normalized image. */ export const DEFAULT_NORMALIZED_IMAGE_MAX_BYTES = 4 * 1024 * 1024 /** Conservative default number of simultaneous native image transformations per store. */ @@ -61,7 +65,9 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent normalized image. */ + /** Total-pixel budget of the stored provider-independent normalized image. */ + normalizedImageMaxPixels?: number + /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */ normalizedImageMaxDimension?: number /** * Encoded-byte target of the stored provider-independent normalized image; @@ -142,6 +148,7 @@ export class LocalAttachmentStore extends AttachmentStore { maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), + normalizedImageMaxPixels: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS), normalizedImageMaxDimension: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION), normalizedImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_BYTES), imageCompressionConcurrency: z.number().step(1).min(1).max(MAX_IMAGE_COMPRESSION_CONCURRENCY) @@ -170,6 +177,7 @@ export class LocalAttachmentStore extends AttachmentStore { mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) this.normalizationPolicy = Object.freeze({ + maxPixels: config.normalizedImageMaxPixels ?? DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, maxDimension: config.normalizedImageMaxDimension ?? DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, maxBytes: config.normalizedImageMaxBytes ?? DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts index 4d679912a8..22db15b740 100644 --- a/packages/attachment/attachment-local/src/normalization.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -3,13 +3,16 @@ import sharp, { type Sharp } from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' -import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' +import { encodeFirstWithinLimit, encodingLadder, isExhaustedEncoding } from './encoding.ts' +import { requestImageDimensions } from './request-image.ts' import { detectImage, encodedAlphaIsCompatible } from './image.ts' import type { DetectedImage } from './image.ts' /** Deployment-resolved policy for the persisted normalized attachment. */ export interface NormalizationPolicy { - /** Long-edge cap in pixels; larger sources are downscaled proportionally. */ + /** Total-pixel budget; larger sources are downscaled proportionally. */ + maxPixels: number + /** Long-edge cap in pixels applied after the total-pixel budget, bounding extreme aspect ratios. */ maxDimension: number /** Encoded-byte target for the quality ladder; the smallest ladder output is kept when no quality fits. */ maxBytes: number @@ -23,24 +26,6 @@ export interface NormalizedImage { height: number } -/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */ -export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const -/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */ -export const WEBP_ENCODING_EFFORT = 0 - -/** Encode one prepared pipeline and report exact output facts. */ -async function encode( - pipeline: Sharp, - mediaType: 'image/jpeg' | 'image/webp', - quality: number, -): Promise { - const encoded = mediaType === 'image/webp' - ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT }) - : pipeline.jpeg({ quality }) - const { data, info } = await encoded.toBuffer({ resolveWithObject: true }) - return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } -} - /** * Whether bytes already satisfy the normalization requirements. * @param detected - fully decoded source facts. @@ -59,6 +44,7 @@ export function canPassThroughNormalization( && detected.depth === 'uchar' && detected.space === 'srgb' && bytes <= policy.maxBytes + && detected.width * detected.height <= policy.maxPixels && Math.max(detected.width, detected.height) <= policy.maxDimension } @@ -92,33 +78,22 @@ function preparedPipeline(data: Uint8Array, width: number, height: number): Shar .resize({ width, height, fit: 'inside', withoutEnlargement: true }) } -/** Dimensions after the long edge is capped without changing aspect ratio. */ -function initialDimensions(detected: DetectedImage, maxDimension: number): { width: number; height: number } { - const scale = Math.min(1, maxDimension / Math.max(detected.width, detected.height)) +/** Dimensions under the total-pixel budget, then the long-edge cap, without changing aspect ratio. */ +function initialDimensions(detected: DetectedImage, policy: NormalizationPolicy): { width: number; height: number } { + const budgeted = requestImageDimensions(detected.width, detected.height, policy.maxPixels) + const longEdge = Math.max(budgeted.width, budgeted.height) + if (longEdge <= policy.maxDimension) return budgeted + const scale = policy.maxDimension / longEdge return { - width: Math.max(1, Math.round(detected.width * scale)), - height: Math.max(1, Math.round(detected.height * scale)), + width: Math.max(1, Math.floor(budgeted.width * scale)), + height: Math.max(1, Math.floor(budgeted.height * scale)), } } -/** Lazy quality ladder: WebP keeps a source alpha channel, everything else is JPEG. */ -function encodingAttempts( - data: Uint8Array, - width: number, - height: number, - hasAlpha: boolean, -): Array<() => Promise> { - const prepared = preparedPipeline(data, width, height) - const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' - return IMAGE_ENCODING_QUALITIES.map(quality => ( - () => encode(prepared.clone(), mediaType, quality) - )) -} - /** * Produce the persisted provider-independent normalized version of one fully decoded source. * The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA, - * and inside both normalization limits. Re-encoding never removes transparency. When every + * and inside every normalization limit. Re-encoding never removes transparency. When every * ladder quality exceeds the byte target, the smallest ladder output is kept; provider byte * caps stay enforced at the route that transmits the bytes. * @param data - complete admitted source bytes. @@ -135,9 +110,9 @@ export async function normalizeImage( return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } } try { - const { width, height } = initialDimensions(detected, policy.maxDimension) + const { width, height } = initialDimensions(detected, policy) const encoded = await encodeFirstWithinLimit( - encodingAttempts(data, width, height, detected.hasAlpha), + encodingLadder(preparedPipeline(data, width, height), detected.hasAlpha), policy.maxBytes, ) const chosen = isExhaustedEncoding(encoded) ? encoded.smallest : encoded diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index 1764eded3a..598e79a893 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -12,8 +12,13 @@ import type { RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { IMAGE_ENCODING_QUALITIES, WEBP_ENCODING_EFFORT } from './normalization.ts' -import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' +import { + IMAGE_ENCODING_QUALITIES, + WEBP_ENCODING_EFFORT, + encodeFirstWithinLimit, + encodingLadder, + isExhaustedEncoding, +} from './encoding.ts' import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ @@ -116,31 +121,6 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp { return sharp(attachment.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') } -async function encoded( - image: Sharp, - mediaType: 'image/jpeg' | 'image/webp', - quality: number, -): Promise { - const output = mediaType === 'image/webp' - ? image.webp({ quality, effort: WEBP_ENCODING_EFFORT }) - : image.jpeg({ quality }) - const { data, info } = await output.toBuffer({ resolveWithObject: true }) - return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } -} - -function encodingAttempts( - attachment: StoredImageAttachment, - width: number, - height: number, - hasAlpha: boolean, -): Array<() => Promise> { - const prepared = pipeline(attachment, width, height) - const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' - return IMAGE_ENCODING_QUALITIES.map(quality => ( - () => encoded(prepared.clone(), mediaType, quality) - )) -} - async function createRequestImage( attachment: StoredImageAttachment, policy: ImageRequestPolicy, @@ -158,7 +138,7 @@ async function createRequestImage( } } const encodedVersion = await encodeFirstWithinLimit( - encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha), + encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha), policy.maxBytes, ) return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index da0cef1400..4d9d9f350b 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -9,6 +9,7 @@ import sharp from 'sharp' import LocalAttachmentStore, { DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, DEFAULT_IMAGE_COMPRESSION_CONCURRENCY, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_IMAGE_DIMENSION, @@ -34,6 +35,7 @@ describe('local attachment service', () => { mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) expect(service.normalizationPolicy).toEqual({ + maxPixels: DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, maxDimension: DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) diff --git a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts index 4e39223f24..4a9faf6d7d 100644 --- a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts @@ -29,7 +29,7 @@ describe('normalization verification', () => { const detected = await detectImage(data) control.mismatch = true - await expect(normalizeImage(data, detected, { maxDimension: 5, maxBytes: 4 * 1024 * 1024 })) + await expect(normalizeImage(data, detected, { maxPixels: 2048 * 2048, maxDimension: 5, maxBytes: 4 * 1024 * 1024 })) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', diff --git a/packages/attachment/attachment-local/tests/normalization.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts index 6403e1a0b4..d60989075d 100644 --- a/packages/attachment/attachment-local/tests/normalization.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization.spec.ts @@ -4,7 +4,7 @@ import { canPassThroughNormalization, normalizeImage } from '../src/normalizatio import type { NormalizationPolicy } from '../src/normalization.ts' import { detectImage } from '../src/image.ts' -const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 4 * 1024 * 1024 } +const POLICY: NormalizationPolicy = { maxPixels: 2048 * 2048, maxDimension: 8192, maxBytes: 4 * 1024 * 1024 } /** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */ function noisePixels(width: number, height: number): Uint8Array { @@ -34,13 +34,14 @@ async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | describe('canPassThroughNormalization', () => { it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => { const clean = { animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false } - expect(canPassThroughNormalization({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 8192, height: 4, ...clean }, 100, POLICY)).toBe(true) expect(canPassThroughNormalization({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false) - expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 2048, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 8193, height: 4, ...clean }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) }) }) @@ -76,19 +77,20 @@ describe('normalizeImage', () => { const data = await flatImage(10, 6, 'png') const detected = await detectImage(data) - const normalized = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3 }) await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) - const again = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const again = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(again.data).toEqual(normalized.data) }) it('re-encodes the normalized output of a resize into itself (idempotence)', async () => { const data = await flatImage(10, 6, 'png') - const first = await normalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const budget = { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes } + const first = await normalizeImage(data, await detectImage(data), budget) - const second = await normalizeImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const second = await normalizeImage(first.data, await detectImage(first.data), budget) expect(second.data).toBe(first.data) }) @@ -109,7 +111,7 @@ describe('normalizeImage', () => { const data = await flatImage(9, 5, 'webp', true) const detected = await detectImage(data) - const normalized = await normalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 4, maxBytes: POLICY.maxBytes }) expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 4, height: 2 }) await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true }) @@ -132,6 +134,7 @@ describe('normalizeImage', () => { await expect(detectImage(data)).resolves.toMatchObject({ hasAlpha: true }) const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, maxDimension: 32, maxBytes: POLICY.maxBytes, }) @@ -154,7 +157,9 @@ describe('normalizeImage', () => { } const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 4 } }).png().toBuffer()) - const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, maxDimension: side, maxBytes: 1_024, + }) expect(normalized.data.byteLength).toBeGreaterThan(1_024) expect(normalized).toMatchObject({ mediaType: 'image/webp', width: side, height: side }) @@ -165,7 +170,7 @@ describe('normalizeImage', () => { const data = await noiseImage(64, 32, 'jpeg') const detected = await detectImage(data) - const normalized = await normalizeImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 32, maxBytes: POLICY.maxBytes }) expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) }) @@ -183,7 +188,7 @@ describe('normalizeImage', () => { } const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 } }).png().toBuffer()) const detected = await detectImage(data) - const budget = { maxDimension: 128, maxBytes: POLICY.maxBytes } + const budget = { maxPixels: POLICY.maxPixels, maxDimension: 128, maxBytes: POLICY.maxBytes } const normalized = await normalizeImage(data, detected, budget) @@ -195,7 +200,9 @@ describe('normalizeImage', () => { it('keeps the smallest opaque ladder output above an unreachable byte target', async () => { const data = await noiseImage(64, 64, 'png') - const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, maxDimension: 2048, maxBytes: 512, + }) expect(normalized.data.byteLength).toBeGreaterThan(512) expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 64, height: 64 }) @@ -262,6 +269,26 @@ describe('normalizeImage', () => { }) }) + it('downscales by total pixels so an extreme aspect ratio keeps its short edge', async () => { + const data = await flatImage(10, 40, 'png') + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: 100, maxDimension: 8192, maxBytes: POLICY.maxBytes, + }) + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 20 }) + }) + + it('caps the long edge after the total-pixel budget', async () => { + const data = await flatImage(4, 64, 'png') + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: 10_000, maxDimension: 16, maxBytes: POLICY.maxBytes, + }) + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 1, height: 16 }) + }) + it('keeps an antialiased text screenshot readable on the JPEG ladder', async () => { const source = new Uint8Array(await sharp(Buffer.from(` @@ -271,6 +298,7 @@ describe('normalizeImage', () => { `)).removeAlpha().png().toBuffer()) const normalized = await normalizeImage(source, await detectImage(source), { + maxPixels: POLICY.maxPixels, maxDimension: 512, maxBytes: POLICY.maxBytes, }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index d1721ceacd..3ff58eb2da 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -39,7 +39,7 @@ const PNG = Uint8Array.from(Buffer.from( 'base64', )) -const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } +const POLICY: NormalizationPolicy = { maxPixels: 2048 * 2048, maxDimension: 8192, maxBytes: 1024 * 1024 } const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, @@ -157,7 +157,7 @@ describe('local attachment store', () => { const saved = await saveImageFile(storageRoot, { data: oversized, mediaType: 'image/png', name: 'big.png', - }, { ...LIMITS, maxImagePixels: 64 }, { maxDimension: 2, maxBytes: 1024 * 1024 }) + }, { ...LIMITS, maxImagePixels: 64 }, { maxPixels: POLICY.maxPixels, maxDimension: 2, maxBytes: 1024 * 1024 }) expect(saved).toMatchObject({ mediaType: 'image/jpeg', diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index ccfc3a3f98..3e8add393f 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -121,7 +121,7 @@ export abstract class AttachmentStore extends Service { /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel and encoded-byte budget. + * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0798c8f31c..eea3a05e2b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -459,7 +459,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', - parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget.' }, { name: 'signal', description: 'optional cancellation.' }], + parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request bytes and the cache/upload identity covering every transform input.', }, ], diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 5aaad0eff0..92761bb840 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -150,7 +150,7 @@ export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600 export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000 /** Total-pixel budget matching provider low-detail image input. */ export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512 -/** Encoded-byte cap for one deterministic model-request image. */ +/** Encoded-byte target for one deterministic model-request image; the smallest quality-ladder output is used when no quality fits. */ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 /** Deterministic raw-byte removal step. */ export const DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM = 64 * 1024 * 1024 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6121212098..034878a1ce 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 1ae0e641f7ffaebc1c2c1060e143c72a40631396 -README.zh.md: 17bf059ea9bb3fbbb80cadc0c9eefed76af26abd +README.md: 31e40e5f0fa3c1e7e0ae0df05aa0a76d54d120b0 +README.zh.md: cd40804ce5908aebd0c35011ad1d56879834164d diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 1ae0e641f7..31e40e5f0f 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64. Its descriptor exposes the attachment id and actual request-image dimensions, plus a normalized-object path only when the attachment provider exposes a host object and the current filesystem maps it into the tool execution world. The path is resolved separately from the request version and its `variantId`. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen 1MiB-target versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64. Its descriptor exposes the attachment id and actual request-image dimensions, plus a normalized-object path only when the attachment provider exposes a host object and the current filesystem maps it into the tool execution world. The path is resolved separately from the request version and its `variantId`. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 17bf059ea9..cd40804ce5 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64。对应描述会公开附件 ID 和实际请求图片尺寸;只有附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,描述才会加入规范化对象路径。该路径独立于请求版本及其 `variantId`。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 目标生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64。对应描述会公开附件 ID 和实际请求图片尺寸;只有附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,描述才会加入规范化对象路径。该路径独立于请求版本及其 `variantId`。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b01b3521ff..e5a7e608b9 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -54,7 +54,7 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 /** Default total-pixel budget preserves the complete 2048px normalized attachment. */ export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048 -/** Default raw encoded-byte cap before inline base64 expansion. */ +/** Default raw encoded-byte target before inline base64 expansion; the smallest quality-ladder output is used when no quality fits. */ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 /** Context capacity assumed for a model neither configuration nor the catalog sizes. */ @@ -193,7 +193,7 @@ export interface ResolvedPiAiProviderProfile maxRequestImageBytes: number /** Positive total-pixel request-version budget after defaulting. */ requestImagePixelBudget: number - /** Positive raw request-version byte cap after defaulting. */ + /** Positive raw request-version byte target after defaulting; the smallest quality-ladder output is used when no quality fits. */ requestImageMaxBytes: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy From cfacca1b07144c1b2488d624c17de30f0279ddd2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 17:15:59 +0800 Subject: [PATCH 057/138] test(attachment): pin the assembled image re-encoding path in a keyless snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-image-reencode lane feeds a 16-bit gradient PNG through the shipped app: pass-through is impossible, the master converts down the opaque JPEG ladder, and the 640,000-pixel request budget re-encodes a downscaled request version — the projection the byte-identical tiny fixtures never exercised. --- examples/acp-agent/tests/acp.snapshot.ts | 12 ++++++++ .../snapshots/read-image-reencode/input.json | 14 +++++++++ .../read-image-reencode/session.jsonl | 29 ++++++++++++++++++ .../read-image-reencode/stdout.expected.jsonl | 6 ++++ .../workspace/gradient.png | Bin 0 -> 22565 bytes 5 files changed, 61 insertions(+) create mode 100644 examples/acp-agent/tests/snapshots/read-image-reencode/input.json create mode 100644 examples/acp-agent/tests/snapshots/read-image-reencode/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-reencode/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-reencode/workspace/gradient.png diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3e8f3aa982..f6bd6124e7 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -262,6 +262,18 @@ const SCENARIOS: Scenario[] = [ headerClass: 'image', configPath: IMAGE_CONFIG, }, + // Authored keyless replay of the re-encoding path: the 900x1200 16-bit + // gradient PNG cannot pass through, so the master converts down the opaque + // JPEG ladder and the 640,000-pixel request budget re-encodes a downscaled + // request version — the assembled projection the tiny byte-identical + // fixtures above never exercise. + { + name: 'read-image-reencode', + hasModelTurn: true, + recorded: false, + headerClass: 'image', + configPath: IMAGE_CONFIG, + }, { name: 'inline-image-prompt', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/read-image-reencode/input.json b/examples/acp-agent/tests/snapshots/read-image-reencode/input.json new file mode 100644 index 0000000000..f08307ffed --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-reencode/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use read_image to look at gradient.png in the current directory, then reply with exactly the single word DONE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/read-image-reencode/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-reencode/session.jsonl new file mode 100644 index 0000000000..c6a2466090 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-reencode/session.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333334","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at gradient.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use read_image to look at gradient.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"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":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Use read_image to look at","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"2b71c837-237d-4d92-a857-8b8ad1a3f237"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-reencode-call"},"content":[{"type":"tool-result","toolCallId":"read-image-reencode-call","content":[{"type":"text","text":"{{cwd}}/gradient.png\nimage\n\nimage/jpeg image, 840x840 px, 10162 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:a9725ac66134512fc8e8971cb0356f9d217529493073d361724ca87c9c7aa968","mediaType":"image/jpeg","bytes":10162,"width":840,"height":840,"name":"gradient.png"}}],"isError":false}],"role":"user","id":"d52141fc-3f8a-42f9-bff0-a44f5a02579a"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"5a45946c-b9f4-4f2c-a7c3-2569e541ec1d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-reencode/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-reencode/stdout.expected.jsonl new file mode 100644 index 0000000000..a1153f758c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-reencode/stdout.expected.jsonl @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"read-image-reencode-call","title":"read_image","kind":"other","status":"in_progress","rawInput":{"file_path":"gradient.png"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-image-reencode-call","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/gradient.png\nimage\n\nimage/jpeg image, 840x840 px, 10162 bytes\n"}},{"type":"content","content":{"type":"image","data":"/9j/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCANIA0gDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECBv/EABYQAQEBAAAAAAAAAAAAAAAAAAARYf/EABoBAQEBAQEBAQAAAAAAAAAAAAADAQIEBgj/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEhH/2gAMAwEAAhEDEQA/AO5pUHz78jytKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStEAlBmldLy0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0CQZG4vLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEpSoNxeVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVogYSgg3F8UQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMZpQavJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJQAlkSldPRKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUCUEG4tKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKCUrceiVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEoYSgyNXloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAkpUul10vK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0S6BLIlK16JUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSgSlKg3FpWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWiBhKUqUrrHolaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaJQwlBmlavLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzQJSlQavK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0QCUEpXS8qJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJQJSlQbi8rSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErRAwlmlSlbi8rSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErRKGEoINeiVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlKVB0tK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0QCUGaVuPRLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzQwkGRuLy0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhKANxeQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwlkQavKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASlKg6XlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaIBLIUrceiQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKGEoMjcXxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDEpUpWrytKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStEoEoJSul5USlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSgSlKg1eVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVogEsiUrcXlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKGEpSoOsXxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaIGGJSpStXlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaJQJQSla9EqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJQJZpQbi0lKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElAMJZEpXWPRKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUMJQQbi+KIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGJSoNXlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaIBKDI6eiWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCSlZGrS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1RkCUEpW49EqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJQwlLpdQbi8rdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdEDCWQpWryBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBQJZEHS8qIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKUqXS63F5WlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWiXQwlBKVuLyolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolDCUpWR0vjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVGQMSlSlavK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0SgSglK16JUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSgSlKg6xaVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVogYSgzStx6JaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaGEgDV8AAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAx//2Q==","mimeType":"image/jpeg"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image-reencode/workspace/gradient.png b/examples/acp-agent/tests/snapshots/read-image-reencode/workspace/gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..bfb45f69317acbad7fc0e077e0d2143b92b23561 GIT binary patch literal 22565 zcmb_k3sltQ630irLa~RDLZDgK2Z)NvLl7}7BtsAdBoNlTNXGJzz3j39i-hGZuV^BW zfvhH_hQJ1jAc&YL^4c{E1q9t5>}C>fY_e>wc)zdm^l*lI#&gfflLy@Y&dxXUo8SCq zzJ14Uh1b|Ilg5~tnT_>cw$$IuY}hjXXZ%q1lcy(aQL+Dxj$0NiVn6(z{~2;D$v?`> zY_gg6(#5M2^SZBGH{2EN9ib~sXjr|<=XT8_ZSVLmow9Vw?&o)<_|&fm%ktaecggRi zk9_?5X6-L6X?eNq=5qF5iR(4}CQW$%T4x(cvBB6;rTo@CcCGf7x1;vo&v1C^e|`JkUgv&JpdIxWvwr{i$KBq+Ho14}!B-Wmvn!lFWi(($;t|-qL}6QI zse80;#r8<=cH0Yc8wA~dHbNgf+HVpfj+IEk~ub4FQ=M5h+}t~R#6?h z3UTZz?%2b6TdH-5pCFEXojDeX@`DZ|st$J9I)b^ov9)35OJE-6Z%~Ma19+4l*jHeG zYW`grG24cu@0|)Hz|Qjhb9`0T$XL(`M}+%HjsAz+g~A$7Vyx&~@q0hAt7IPa z^dped4?r_c9lY&yPn*6(Lxi%#awMk3ydF(U-2cST8M+dTGh) z<-=Ne6Y-=3@aMhg^mrglg{tG`ww|1xnn!#e-P1@!e8?l>C({9HM1%=p%fmw1NzJae z-2fsgPVuTghtaIMfT0-zJIfjDEZ5q?K0h@65JLVABgwXrC0n)qDLK`(j)-eZxoeH4 zlVsP%Ag&E#t}Qu#(FE%ck2icFP3Q}I% z3qI0siXTGgUP2twz#S4Gn+85^=i5aHk5LDVaP#^TC%Z?@)d3N{4W%a2St?s^w1P=* z7Xt;P1F=8#WOv|sGM)t@T*{8aMWI#&7mC%RRwK#ZjwHzmmL!2qQ>g{mZW8Q>3AiJU zR4_+;V4)%=iDDO=bZZVX%fm9aQmI+kdL&0yu^h2ejf7bx>ewR)ngu#ACKAx=NhQ5A z;fNJvu?#>u!a|m70ops%*7(TlupN|Pj#z$-Sst8n)eS7b`<~y2v>*vE z6e!-n$$E=rdgr7{Do6;%BJcDfd#59=H}i?0Bk)0K&fmQo|w&;ZacxDI)e7GpRN1(p=abH#jh!Fm9sS zcLE99PCVguj1!mSN?`IZ2(?}S$A#_$FK#I@Dk}9q2?Jwd3Xe1A*j#GdE`;qr?l!#R~LXlf%k5;R!D?d@d&$PnnU$N3gQPD^Mi0s>>ld#h9S>e zjQe#g^J{Lrh#JeeNVk>i>6GW(Of9_o;rMD*u)^!qu$@X7tR}*gVLOv^5bh*A`egT2 z_t^J|9#=QRIjwGHw68Zil+4qgfKTwkKXz`{CQ$3H5Mc~XoH6*6{`?*!p$PpISrM6` zxgXhr+DufAF>u2tQvs?wd7(u+B2qa8n~QUmQ81IlCGgg-U@9qmg%POO<`!yP#LkBS zJ$h)HFl;^*3^u?=FDS#|Ja^WJs%CvU6~vkm*wOIb;;d9t!&`6|Vt;k?a7|!6(tqGog3br!M#jUAC zh2VL~Y<*GFYA;}{0gZcIToG?kF~xeW|Fx_r*hV{53wDK5}%I4G^B`qd3#PkV9p)XmtFSef(Jelxtx zbnD(Oz&%Y5pJR!U({$t5>7d_6#kB5MVN4UxV9{ad`i6*UgN((hl-tO}rUfQ8-Hg~E z0;AwE8zP^L1HS{6WCW%vvewJ*jOYCmTW_`ZyG)^csoDmX?A9nFlYNH%%Q>NV|;? zh!mVabTb0c*BA`97-Q0qVZ@vQsl3q| z>9ohO1VHBBak^I(w=bBQsE)NoL|WUKEY~cwyWw1W%M)oCwjvR>m7mj2X)o2htq93m zg0lzf(kHt;cKJqv7>7?|laXm`GM~nd+bjQoSR&MZa7@I##!7^Bb|zdRCScohTM-&{ zhBw)|N?3fc4pU@x>7?Hvn=dY#_QXbfyZ~6@8-7v0uLz!RsC6uZm#!dV#b-#$tz;?Z zrSw&T8H|h4aRso*=Tgd!E2H5jBGMi}kai_QMnr|mc*R`orShe+^|+&m;e4Q(QC~;h zAIU(*3U!=1e5zkhExy)KFw@Oo(=`Npbl(dMU;GC~3VaW92LkCkI7iu}ub^_C3}hj- zj*Xi_GAbB$R=ex%pGbi4^4mxD$b?F-9^Uh(qNS)?Ws%Mj%;1ozc!cHE+(*x|C zL@!g7o;n1D>o^KOrvIMmT90*bOnC6*tAaa!l%+c_h7IJ4;qLt5Py-Jz(DzzZzAv%MS z>kOjOAZmGVg~LnDSCCyL4k_RUvi1of`(=-7jtp#A`a(4!dk$yZTLAo%u%7lbCL4&@ zI+3((Dm6RYVf>BbYZQ5lBvg$|os2w>uOo!+wD>3ZdGQ7<6cP6vrlZ6(?lfzPKkJAl zs*;1KvzD4W)Tp&Yc<#fTjD1*ngPLuY2*_^3TjEeAs5>Zc!!0K+qB|fWx+NFUEwv8R zd_#J9HV!lTi>YO;A6c*L_vhi|C?WUjk0Rmf&7Ozob9@^+M}L)yzq66S?riSYAWafg z|3}1Qb}dY_QQSnN3l+$gMCc%N;Uc4tEyf-y&8a~aIcxYrMtCE6;jjj|yi~(4FNMq9 zsI58zIP9i^rqmur^PCC7h)Vk_k=H@8D!Bb1E9s3a%A7S~YPKbxfmuWH0mhzc_DHCr zWHQ1+lFRt><{yVw=J6eOL_98to_++r#n69=p}Dx6dX2CjxoOnTZyF^>#w-MnYLK-( z2|}MFoaOdblk09g0z6V2cp6!SmuRn0iDZ87S-365ntcnzJA-N zp;qJ=B%z&oLT_)IPL1Lh5olYFLz@+YHXqR$vgL>r<;*V|G)u{MxUo%K4eRA9BVy)I z@nIH{&)K*bb(~#~vE6Wo$}Vk@e#4gQ>P7nPR8a0fSXvCfZe!aumKw8*kds=(CyrM% zZ;6@>!3 z#kZ^Q=E?OTI-V zn2V4YTf_=TwmkAJY5|rbq)g6ZY__JCTHs9Z>cl|FTDRaag=5mqI+SJSq)soUT+n~8 z&Sv=V;aj$hKev9RVammAv0onVDZWvd+LDl;WNbVl zVZfCdLde(Ltl>oCnCU&LbqFuG#H~y5kbxk7gK_&y+A?E5csVt(@7!_6RZqd&0vC|5 zRMeP@t&9ta$Ra@08zF~mO)~ke;m0u5XyxBKQ)J4f`WE@3m~!S@WXWGRE4{PNltArU zqv1-ZSj!5zn~{pwf;w1W=;mJSF-fV`twpR0K?#>YGO>F==}RTeFt|>RUb3!Fvx5pK z-N*#tB!lWUl?Bzd+puwhQ;fxq(-^4MS>NC9|JviH2kKgz(t?o3@tdZh%@$OrRv=&##P>lK+@x+&Blyk6yvWv7 zNzUn>H)@=zbnzIBTooL-a;tNRlqL?@xr*V^vbcu&#eB7xw2ZIO##Nb%B~mvcQB)(} zlG*WVhnyTmMXe+RwfL=23vXgMs7;5@D3R@K^etNakXOkehxL=#_x_rBzp!Fy@$-T= F{|n3MEMEWs literal 0 HcmV?d00001 From 1d4dcf3b576a913ca3715167aba389d87c03dd3c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:55:26 +0800 Subject: [PATCH 058/138] refactor(python): remove the private direct-config carrier Delete @deepseek-ai/dsh-sdk-python-runtime and its packaged-bin entry now that Python uses the repository's dsh application launcher. Remove the package's project reference, Knip entry, workspace-policy exception, executable allowlist entry, and README-model classification together so no tooling preserves the old exception. This commit is deliberately mechanical: it removes the obsolete package and gate accommodations without introducing the replacement launch behavior. The following commits add the packaged dsh runtime and Python profile API, keeping the architecture change separate from deletion noise. --- knip.json | 5 -- packages/sdk/python-runtime/README.i18n.yaml | 6 --- packages/sdk/python-runtime/README.md | 34 ------------ packages/sdk/python-runtime/README.zh.md | 34 ------------ packages/sdk/python-runtime/package.json | 48 ----------------- packages/sdk/python-runtime/src/index.ts | 10 ---- packages/sdk/python-runtime/src/invariant.ts | 30 ----------- .../sdk/python-runtime/src/packaged-bin.ts | 12 ----- packages/sdk/python-runtime/src/runner.ts | 54 ------------------- packages/sdk/python-runtime/tsconfig.json | 24 --------- packages/sdk/python-runtime/tsdown.config.ts | 17 ------ scripts/check-workspace-constraints.spec.ts | 24 --------- scripts/check-workspace-constraints.ts | 12 ----- .../verify-application-entrypoints.spec.ts | 10 ++-- scripts/verify-application-entrypoints.ts | 7 ++- .../verify-package-readme-model-experience.ts | 1 - tsconfig.host.json | 1 - 17 files changed, 9 insertions(+), 320 deletions(-) delete mode 100644 packages/sdk/python-runtime/README.i18n.yaml delete mode 100644 packages/sdk/python-runtime/README.md delete mode 100644 packages/sdk/python-runtime/README.zh.md delete mode 100644 packages/sdk/python-runtime/package.json delete mode 100644 packages/sdk/python-runtime/src/index.ts delete mode 100644 packages/sdk/python-runtime/src/invariant.ts delete mode 100644 packages/sdk/python-runtime/src/packaged-bin.ts delete mode 100644 packages/sdk/python-runtime/src/runner.ts delete mode 100644 packages/sdk/python-runtime/tsconfig.json delete mode 100644 packages/sdk/python-runtime/tsdown.config.ts diff --git a/knip.json b/knip.json index b6c4e33254..6e03ebf7da 100644 --- a/knip.json +++ b/knip.json @@ -550,11 +550,6 @@ "zod" ] }, - "packages/sdk/python-runtime": { - "project": [ - "src/**/*.ts" - ] - }, "packages/subagent/subagent-spawn-in-process": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/sdk/python-runtime/README.i18n.yaml b/packages/sdk/python-runtime/README.i18n.yaml deleted file mode 100644 index d2b2859c7c..0000000000 --- a/packages/sdk/python-runtime/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/sdk/python-runtime/README.md -README.md: 291ad3edaff8182007079de8e41f33e91546e5d7 -README.zh.md: 54001ccfffb356db290d7c7b38db41070d8dd661 diff --git a/packages/sdk/python-runtime/README.md b/packages/sdk/python-runtime/README.md deleted file mode 100644 index 291ad3edaf..0000000000 --- a/packages/sdk/python-runtime/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# @deepseek-ai/dsh-sdk-python-runtime - -English | [中文](README.zh.md) - -Private direct-config carrier for the temporarily unchanged Python SDK runtime. Its [`jsonrpc`](../server/README.md) entry serves SDK clients over newline-delimited stdio, while an external `cordis.yml` composes the spine, backends, and serving plugin. This npm package exposes no public bin and is not published; the Python SDK's existing `dsh-jsonrpc-agent-pkg--` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) packages `lib/packaged-bin.js` from the closed deploy tree. Bare plugins resolve from that tree, while relative plugins remain configuration-relative. - -## Config discovery - -The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the packaged entry prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../../boot/app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`. - -A config without `dsh-sdk-jsonrpc-server` is valid and serves nothing; the carrier does not designate a server plugin. - -## Exit lifecycle - -stdin EOF and `SIGTERM` dispose the root to quiescence and exit 0; `SIGINT` exits 130 after the same disposal. EOF may cut off an in-flight turn as documented in the [distribution Agent Note](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). The `jsonrpc` plugin owns response-before-exit protocol shutdown; both paths are idempotent and safe to race. - -## stdout is the protocol - -stdout carries only JSON-RPC frames. The carrier and boot guards diagnose on stderr, and the config must omit stdout loggers. - -## Model Experience - -Indirectly, through the plugins loaded from the external `cordis.yml`, which own every model-bound prompt, schema, message, and result; this carrier adds none of its own. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - -- **Temporary direct-config exception** — this private carrier remains outside `dsh --profile sdk` only to preserve the current Python executable and wheel behavior; the later Python runtime migration deletes it and then renames the executable family. -- **The carrier cannot prove that the config serves JSON-RPC** — a valid config with no `dsh-sdk-jsonrpc-server` entry boots successfully and serves nothing. -- **No built-in or default config exists** — every launch must provide `DSH_CORDIS_CONFIG` or a positional path, and deployment owns the complete plugin tree and stdout discipline. -- **stdin EOF cuts off in-flight work** — client disappearance disposes the root immediately; callers that need orderly completion use the protocol-level `shutdown` request. diff --git a/packages/sdk/python-runtime/README.zh.md b/packages/sdk/python-runtime/README.zh.md deleted file mode 100644 index 54001ccfff..0000000000 --- a/packages/sdk/python-runtime/README.zh.md +++ /dev/null @@ -1,34 +0,0 @@ -# @deepseek-ai/dsh-sdk-python-runtime - -[English](README.md) | 中文 - -这是为暂时保持不变的 Python SDK 运行时提供的私有直读配置载体。其 [`jsonrpc`](../server/README.zh.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务,外部 `cordis.yml` 则负责组合主干、后端和服务插件。该 npm 包不公开 bin,也不会发布;Python SDK 既有的 `dsh-jsonrpc-agent-pkg--` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)从封闭部署树打包 `lib/packaged-bin.js`。裸插件从该树解析,相对插件仍以配置目录为基准。 - -## 配置发现 - -第一个非空通道生效:先 `$DSH_CORDIS_CONFIG`,再位置参数 `argv[2]`。如果二者都没有指向现有文件,打包入口会向 stderr 打印单行用法并以 1 退出;没有工作目录回退或内置回退。[`dsh-app-boot`](../../boot/app-boot/README.zh.md) 会使插件加载失败成为致命错误。此协议不使用 `DSH_SNAPSHOT`。 - -不含 `dsh-sdk-jsonrpc-server` 的配置仍然有效,只是不提供任何服务;该载体不会指定服务器插件。 - -## 退出生命周期 - -stdin EOF 和 `SIGTERM` 会 dispose(释放资源)根上下文,等待完全停稳后以 0 退出;`SIGINT` 完成同样的 dispose 后以 130 退出。EOF 可能按[分发 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md) 所述截断正在处理的轮次。`jsonrpc` 插件拥有先响应再退出的协议关闭流程;两条路径均幂等,即使发生竞态也安全。 - -## stdout 是协议 - -stdout 只承载 JSON-RPC 帧。该载体和启动守卫在 stderr 上输出诊断,配置必须省略 stdout logger。 - -## 模型体验 - -模型体验由外部 `cordis.yml` 加载的插件间接提供;这些插件负责所有面向模型的提示词、schema、消息和结果,该载体不添加任何内容。 - -#### KV Cache 影响 - -不会直接失效;由上述消费方负责请求前缀的任何变更。 - -## 已知限制与暂缓事项 - -- **临时直读配置例外**:为了保持当前 Python 可执行文件与 wheel 包行为不变,该私有载体暂时不经过 `dsh --profile sdk`;后续 Python 运行时迁移会删除它,之后再重命名可执行文件族。 -- **载体无法证明配置提供 JSON-RPC 服务**:不含 `dsh-sdk-jsonrpc-server` 条目的有效配置也能成功启动,但不会提供任何服务。 -- **不存在内置或默认配置**:每次启动都必须提供 `DSH_CORDIS_CONFIG` 或位置路径;部署方负责完整的插件树和 stdout 纪律。 -- **stdin EOF 会截断正在处理的工作**:客户端消失时立即释放根上下文;需要有序完成的调用方应使用协议级 `shutdown` 请求。 diff --git a/packages/sdk/python-runtime/package.json b/packages/sdk/python-runtime/package.json deleted file mode 100644 index cdae65348f..0000000000 --- a/packages/sdk/python-runtime/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-sdk-python-runtime", - "description": "Private direct-config runtime carrier for the temporarily unchanged Python SDK", - "version": "0.1.1-rc.2", - "private": true, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/sdk/python-runtime" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./packaged-bin": { - "types": "./lib/types/packaged-bin.d.ts", - "default": "./lib/packaged-bin.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/packaged-bin.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "dependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/sdk/python-runtime/src/index.ts b/packages/sdk/python-runtime/src/index.ts deleted file mode 100644 index 74dabd87bf..0000000000 --- a/packages/sdk/python-runtime/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Private Python SDK runtime carrier: its packaged entry discovers an external - * `cordis.yml` and owns process exit. This module exports no composition plugin; - * the config chooses whether to load the - * {@link @deepseek-ai/dsh-sdk-jsonrpc-server} serving plugin. - * - * @module @deepseek-ai/dsh-sdk-python-runtime - */ - -export {} diff --git a/packages/sdk/python-runtime/src/invariant.ts b/packages/sdk/python-runtime/src/invariant.ts deleted file mode 100644 index 79bb5ec1f6..0000000000 --- a/packages/sdk/python-runtime/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-python-runtime`. - * @module @deepseek-ai/dsh-sdk-python-runtime/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-python-runtime' - -/** Cordis companion plugin name. */ -export const name = 'sdk-python-runtime-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this composition package owns no independent event stream or mutable data; - * Loader and built-entry tests cover its wiring. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/sdk/python-runtime/src/packaged-bin.ts b/packages/sdk/python-runtime/src/packaged-bin.ts deleted file mode 100644 index 1c5e5c3e92..0000000000 --- a/packages/sdk/python-runtime/src/packaged-bin.ts +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env node -/** - * Closed-runtime JSON-RPC agent bin. Bare plugins resolve from the installed - * runtime closure while relative plugins remain configuration-relative. - * - * @module @deepseek-ai/dsh-sdk-python-runtime/packaged-bin - */ - -import { runPythonSdkRuntime } from './runner.ts' - -/* v8 ignore next -- exercised through the built Python runtime carriers */ -await runPythonSdkRuntime(import.meta.url) diff --git a/packages/sdk/python-runtime/src/runner.ts b/packages/sdk/python-runtime/src/runner.ts deleted file mode 100644 index 9850c64a0b..0000000000 --- a/packages/sdk/python-runtime/src/runner.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Process lifecycle for the Python SDK's closed direct-config runtime. - * - * @module @deepseek-ai/dsh-sdk-python-runtime/runner - */ - -import { existsSync } from 'node:fs' -import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' - -/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ -const NAME = 'dsh-jsonrpc-agent' - -/** - * Boot the explicitly selected external configuration and own process exit. - * @param bareModuleBaseUrl - installed-runtime base for bare plugins. - * @returns after process handlers are installed; process lifetime then belongs - * to stdin and signal events. - */ -export async function runPythonSdkRuntime(bareModuleBaseUrl: string): Promise { - installFailLoud(NAME) - loadEnv(NAME) - - // Env wins over argv; empty values are absent. External config defines the deployment. - const fromEnv = process.env['DSH_CORDIS_CONFIG'] - const fromArgv = process.argv[2] - const requested = fromEnv !== undefined && fromEnv !== '' - ? fromEnv - : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined - const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) - if (configPath === undefined || !existsSync(configPath)) { - process.stderr.write( - `usage: ${NAME} (or set DSH_CORDIS_CONFIG=, which wins); the config is required — there is no built-in fallback\n`, - ) - process.exit(1) - } - - const ctx = await boot(NAME, configPath, undefined, undefined, bareModuleBaseUrl) - let exiting = false - - async function disposeAndExit(code: number): Promise { - if (exiting) return - exiting = true - try { - await ctx.fiber.dispose() - } finally { - process.exit(code) - } - } - - process.stdin.on('end', () => { void disposeAndExit(0) }) - process.on('SIGTERM', () => { void disposeAndExit(0) }) - process.on('SIGINT', () => { void disposeAndExit(130) }) -} -/* v8 ignore stop */ diff --git a/packages/sdk/python-runtime/tsconfig.json b/packages/sdk/python-runtime/tsconfig.json deleted file mode 100644 index ffd1ce9e41..0000000000 --- a/packages/sdk/python-runtime/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/loader" - }, - { - "path": "../../boot/app-boot" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/sdk/python-runtime/tsdown.config.ts b/packages/sdk/python-runtime/tsdown.config.ts deleted file mode 100644 index 9b6d5d33a7..0000000000 --- a/packages/sdk/python-runtime/tsdown.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** Builds each published entry as a self-contained file admitted by the package whitelist. */ -export default defineConfig([ - { - entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/packaged-bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, -]) diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 4bd39ac878..dc3b63227f 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -1,12 +1,9 @@ /** Experimental-package publication and dependency constraints. */ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { checkExperimentalDependencyIsolation, checkExperimentalManifest, - checkWorkspaceManifest, type WorkspaceManifest, } from './check-workspace-constraints.ts' @@ -77,24 +74,3 @@ describe('experimental workspace constraints', () => { ]) }) }) - -describe('private Python runtime carrier', () => { - const manifest = JSON.parse( - readFileSync(new URL('../packages/sdk/python-runtime/package.json', import.meta.url), 'utf8'), - ) as WorkspaceManifest['manifest'] - - it('participates in dsh package checks without becoming an npm release member', () => { - expect(checkWorkspaceManifest({ dir: 'packages/sdk/python-runtime', manifest })).toEqual([]) - }) - - it('rejects publication metadata on the private carrier', () => { - const path = join('packages', 'sdk', 'python-runtime', 'package.json') - expect(checkWorkspaceManifest({ - dir: 'packages/sdk/python-runtime', - manifest: { ...manifest, private: false, publishConfig: { access: 'public' } }, - })).toEqual([ - `${path}: @deepseek-ai/dsh-sdk-python-runtime: private carrier must set "private": true`, - `${path}: @deepseek-ai/dsh-sdk-python-runtime: private carrier must omit publishConfig`, - ]) - }) -}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 842e0ca3a7..35e6eb1049 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -54,9 +54,6 @@ const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/ const experimentalPackageNamePrefix = '@deepseek-ai/dsh-experimental-' /** Directories whose packages this repository publishes: one release member each. */ const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/ -/** Named dsh packages that remain private because another distribution embeds them. */ -const privateCarrierDirectories = new Set(['packages/sdk/python-runtime']) - const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { '@deepseek-ai/dsh': ['lib/*.js'], @@ -156,8 +153,6 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], // The shipped preset compositions travel inside the roster package. '@deepseek-ai/dsh-agent-presets': ['presets'], - // The private Python carrier ships only its closed-resolution entry. - '@deepseek-ai/dsh-sdk-python-runtime': ['lib/packaged-bin.js'], // The argv-prefix runner entry ships beside the lib as its own bundle; // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. @@ -292,13 +287,6 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st || manifest.repository.directory !== expectedDirectory) { errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`) } - } else if (privateCarrierDirectories.has(dir)) { - if (manifest.private !== true) { - errors.push(`${label}: private carrier must set "private": true`) - } - if (manifest.publishConfig !== undefined) { - errors.push(`${label}: private carrier must omit publishConfig`) - } } else if (releaseMemberDirectory.test(dir)) { // Release members state that they are publishable: npm refuses a private // package, and the repository field is how a consumer finds the source of diff --git a/scripts/verify-application-entrypoints.spec.ts b/scripts/verify-application-entrypoints.spec.ts index 02ba9a2196..ee590de033 100644 --- a/scripts/verify-application-entrypoints.spec.ts +++ b/scripts/verify-application-entrypoints.spec.ts @@ -74,12 +74,14 @@ describe('application entrypoints', () => { ]) }) - it('accepts the temporary private Python carrier source without an npm bin', () => { + it('rejects a private Python application carrier outside dsh', () => { const root = fixture() - write(root, 'packages/sdk/python-runtime/package.json', JSON.stringify({ private: true })) - write(root, 'packages/sdk/python-runtime/src/packaged-bin.ts', '#!/usr/bin/env node\n') + write(root, 'packages/sdk/rogue-python-runtime/package.json', JSON.stringify({ private: true })) + write(root, 'packages/sdk/rogue-python-runtime/src/bin.ts', '#!/usr/bin/env node\n') - expect(applicationEntrypointViolations(root)).toEqual([]) + expect(applicationEntrypointViolations(root)).toEqual([ + 'packages/sdk/rogue-python-runtime/src/bin.ts: executable source has no application/build/test classification', + ]) }) it('rejects a classified demo wrapper that launches a package entry', () => { diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index 7dec320e79..b273d6aed6 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -1,7 +1,7 @@ /** * Enforce dsh profiles as the only supported Node application launcher. - * Vendor CLIs, build tools, test tools, and the temporary private Python - * runtime carrier are explicit classifications rather than implicit holes. + * Vendor CLIs, build tools, and test tools are explicit classifications + * rather than implicit holes. */ import { existsSync, globSync, readFileSync } from 'node:fs' @@ -43,7 +43,6 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['packages/experimental/webworker-packer/bin.js', 'private build-only wrapper'], ['packages/experimental/webworker-packer/src/bin.ts', 'private build-only implementation'], ['packages/sdk/client/tests/fake-runtime.ts', 'test-only SDK runtime peer'], - ['packages/sdk/python-runtime/src/packaged-bin.ts', 'temporary private Python runtime carrier'], ['packages/test-support/llm-mock-server/src/bin.ts', 'test-only model server'], ]) @@ -194,6 +193,6 @@ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(p for (const failure of failures) console.error(` ${failure}`) process.exitCode = 1 } else { - console.log('verify-application-entrypoints: dsh is the only supported Node application launcher; the private Python carrier remains the temporary exception.') + console.log('verify-application-entrypoints: dsh is the only supported Node application launcher.') } } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1fb184af82..f83b621965 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -167,7 +167,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/jobs/jobs-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-jobs.' }, 'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, 'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' }, - 'packages/sdk/python-runtime': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/interaction/permission-presets': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/interaction/user-questions': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index b64992cdf1..91fbfd03f7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -267,7 +267,6 @@ { "path": "./packages/boot/app-boot" }, { "path": "./packages/boot/cmdline" }, { "path": "./packages/sdk/server" }, - { "path": "./packages/sdk/python-runtime" }, { "path": "./packages/test-support/llm-replay" }, { "path": "./packages/typert/generator" }, { "path": "./packages/test-support/acp-snapshot" }, From 809a4c5bade9c97d9a8b61cb4568f9bbfbf9ffa2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:55:42 +0800 Subject: [PATCH 059/138] fix(app-boot): preserve profile modules inside pkg executables Teach the profile installation fallback to use normal symlinks under Node and real ESM proxy packages under pkg. Each proxy records the source package version, mirrors its explicit runtime subpath exports, and re-exports the virtual /snapshot URLs, so built-in Loader rows and external plugin peers resolve one shared Cordis/module instance from an on-disk profile. Keep proxy healing idempotent, reject foreign real directories, cover root and subpath imports in packaged mode, and expand AggregateError startup diagnostics so concurrent Loader failures retain their individual import causes. This is the reusable packaged-profile mechanism; Python-specific artifact wiring remains in the next commit. --- packages/boot/app-boot/src/index.ts | 6 +- packages/boot/app-boot/src/profile.ts | 146 ++++++++++++++++-- packages/boot/app-boot/tests/app-boot.spec.ts | 22 +++ packages/boot/app-boot/tests/profile.spec.ts | 99 +++++++++++- 4 files changed, 260 insertions(+), 13 deletions(-) diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index ecfe695f34..9a1d05ffbf 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -1,5 +1,5 @@ /** - * Shared boot glue for `dsh` profiles and the temporarily packaged Python SDK runtime: load the gitignored + * Shared boot glue for `dsh` profiles, including the CLI packaged by the Python runtime wheel: load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. @@ -811,7 +811,9 @@ export async function boot( // original activation error instead of only the wrap chain. let deepest: unknown = cause while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause - const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' + const stack = deepest instanceof AggregateError + ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}` + : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) } } diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index cbd6074ff9..e886f759b2 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -24,9 +24,10 @@ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' @@ -238,22 +239,140 @@ function ensureSymlink(link: string, target: string): void { } } +interface ModuleProxyManifest { + name: string + version: string + private: true + type: 'module' + exports: Record + dsh: { moduleFallback: { targets: Record } } +} + +interface ModuleProxyRecord { + version?: unknown + dsh?: { moduleFallback?: { targets?: unknown } } +} + +/** Return whether the process reads application modules from pkg's virtual filesystem. */ +function isPackagedExecutable(): boolean { + return (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined +} + +/** Resolve one package specifier from the dsh installation. */ +function packageEntryFromAnchor(anchor: string, specifier: string): string | undefined { + try { + return createRequire(anchor).resolve(specifier) + } catch { + return undefined + } +} + +/** Resolve every explicit runtime export that an out-of-tree plugin can import. */ +function packageProxySource( + installAnchor: string, + packageName: string, + packageDir: string, +): { version: string; targets: Record } { + const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + exports?: unknown + version?: unknown + } + if (typeof manifest.version !== 'string' || manifest.version.length === 0) { + throw new Error(`dsh: installed package ${packageName} must declare a non-empty version`) + } + const declared = manifest.exports + const subpaths = declared !== null && typeof declared === 'object' && !Array.isArray(declared) + && Object.keys(declared).some(key => key.startsWith('.')) + ? Object.keys(declared).filter(key => key === '.' || (key.startsWith('./') && !key.includes('*') && key !== './package.json')) + : ['.'] + const targets: Record = {} + for (const subpath of subpaths) { + const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) + const entry = packageEntryFromAnchor(installAnchor, specifier) + if (entry !== undefined) targets[subpath] = pathToFileURL(entry).href + } + return { version: manifest.version, targets } +} + +/** + * Materialize a real package proxy whose exports retain pkg's virtual module + * URL. Files outside the executable cannot traverse a symlink into + * `/snapshot`, while an ESM re-export can import that URL and preserves the + * executable's single module instance for out-of-tree plugin peers. + */ +function ensureModuleProxy( + link: string, + packageName: string, + version: string, + targets: Record, +): void { + const proxyExports = Object.fromEntries( + Object.keys(targets).map((subpath, index) => [subpath, `./entry-${index}.js`]), + ) + const manifest: ModuleProxyManifest = { + name: packageName, + version, + private: true, + type: 'module', + exports: proxyExports, + dsh: { moduleFallback: { targets } }, + } + let stat + try { + stat = lstatSync(link) + } catch { + stat = undefined + } + if (stat?.isSymbolicLink()) { + unlinkSync(link) + stat = undefined + } + if (stat !== undefined) { + const marker = join(link, 'package.json') + let existing: ModuleProxyRecord | undefined + try { + existing = JSON.parse(readFileSync(marker, 'utf8')) as ModuleProxyRecord + } catch { + existing = undefined + } + if (existing?.dsh?.moduleFallback?.targets === undefined) { + throw new Error(`dsh: ${link} exists and is not a dsh-managed module proxy; remove it so dsh can manage the installation fallback`) + } + if (existing.version === version + && JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets)) return + rmSync(link, { recursive: true }) + } + mkdirSync(link, { recursive: true }) + writeFileSync(join(link, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') + for (const [index, target] of Object.values(targets).entries()) { + const specifier = JSON.stringify(target) + writeFileSync( + join(link, `entry-${index}.js`), + `export * from ${specifier}\nimport * as target from ${specifier}\nexport default target.default\n`, + ) + } +} + /** * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one - * symlink per package in the dsh app's resolvable dependency CLOSURE (BFS + * entry per package in the dsh app's resolvable dependency CLOSURE (BFS * over `dependencies` from the app manifest), each resolved from its own - * real location. Node's parent-directory walk from any profile finds this + * installation location. Plain Node uses symlinks. A pkg executable writes + * small ESM proxy packages instead because the host filesystem cannot follow + * a symlink into pkg's virtual `/snapshot` tree; the proxy re-exports the + * virtual URL, preserving the executable's single module instance. Node's + * parent-directory walk from any profile finds this * directory after the profile's own `node_modules`, so every in-box plugin * resolves without pnpm ever managing it — the exact "bundles come from the * installation" contract. The closure (not just direct dependencies) is * required for out-of-tree plugins: their peer dependencies name Service * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app - * reaches only through its Service Provider packages. Symlinked packages - * resolve their own dependencies from their real directories (Node's default - * symlink-following), so each package needs only its one flat link. - * Idempotent: correct links are kept and moved installations are - * re-pointed; a stale link to a vanished package stays until its name is - * reused (dangling links are invisible to resolution). + * reaches only through its Service Provider packages. Both a symlink target + * and a proxy's virtual target resolve transitive imports from the original + * package directory, so each package needs one flat fallback entry. + * Idempotent: correct entries are kept and changed installation targets are + * rewritten; under plain Node, a stale dangling link stays until its name is + * reused because resolution cannot discover it. * @param installAnchor - absolute path of the dsh app's package.json. * @param home - the Harness home; defaults to {@link resolveDshHome}. */ @@ -287,7 +406,14 @@ export function healProfilesModuleFallback(installAnchor: string, home: string = for (const [packageName, target] of links) { const link = join(modulesDir, packageName) mkdirSync(dirname(link), { recursive: true }) - ensureSymlink(link, target) + if (isPackagedExecutable()) { + const source = packageProxySource(installAnchor, packageName, target) + if (Object.keys(source.targets).length > 0) { + ensureModuleProxy(link, packageName, source.version, source.targets) + } + } else { + ensureSymlink(link, target) + } } } diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 8726895c1b..8ae1a21cd2 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -771,6 +771,28 @@ describe('boot', () => { ) }) + it('expands a stackless aggregate at the deepest activation cause', async () => { + const dir = tmp() + const aggregate = new AggregateError([ + new Error('first aggregate member'), + 'second aggregate member', + ], 'aggregate activation failure') + delete (aggregate as { stack?: string }).stack + try { + await boot(NAME, join(dir, 'cordis.yml'), undefined, () => { + throw new Error('wrapped aggregate failure', { cause: aggregate }) + }) + expect.fail('boot should reject the aggregate activation failure') + } catch (error) { + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + expect(message).toContain(`${NAME}: host preparation failed: wrapped aggregate failure`) + expect(message).toContain('aggregate activation failure') + expect(message).toContain('first aggregate member') + expect(message).toContain('second aggregate member') + } + }) + it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { const dir = tmp() writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 92265d2fdb..3710859380 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -36,12 +36,18 @@ function stageInstallation(bundles: Record { const fallback = join(home, 'profiles', 'node_modules') expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) }) + + it('writes real ESM proxies for a packaged executable', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + bundleManifest.exports = { '.': './index.js', './feature': './feature.js' } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) + writeFileSync(join(bundleDir, 'feature.js'), 'export const feature = "proxied"\n') + const home = tmp() + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + const proxy = join(fallback, 'bundle-a') + expect(lstatSync(proxy).isDirectory()).toBe(true) + const proxyManifest = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { + version: unknown + exports: unknown + dsh: { moduleFallback: { targets: Record } } + } + expect(proxyManifest).toMatchObject({ + version: '0.0.0', + exports: { '.': './entry-0.js', './feature': './entry-1.js' }, + }) + expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js')) + await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) + await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' }) + healProfilesModuleFallback(anchor, home) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('requires a package version before writing a packaged proxy', () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.version = '' + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + expect(() => { healProfilesModuleFallback(anchor, tmp()) }).toThrow( + 'installed package bundle-a must declare a non-empty version', + ) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('replaces plain-node links and stale managed proxies in packaged mode', () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + healProfilesModuleFallback(anchor, home) + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + expect(lstatSync(proxy).isSymbolicLink()).toBe(true) + + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + healProfilesModuleFallback(anchor, home) + expect(lstatSync(proxy).isDirectory()).toBe(true) + const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { + version: string + } + stale.version = 'stale' + writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale)) + healProfilesModuleFallback(anchor, home) + expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({ + version: '0.0.0', + }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('rejects foreign packaged fallback directories with valid or invalid metadata', () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + for (const metadata of ['{}', '{']) { + const home = tmp() + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + mkdirSync(proxy, { recursive: true }) + writeFileSync(join(proxy, 'package.json'), metadata) + expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow( + 'exists and is not a dsh-managed module proxy', + ) + } + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) }) From be7b0645041ef203f3b6516e4623ab63570390cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:02 +0800 Subject: [PATCH 060/138] feat(python-runtime): package the dsh CLI and profile assets Make the zero-code dsh-python-runtime-closure depend on the real @deepseek-ai/dsh application and every required profile peer, then package apps/cli's built bin instead of the deleted Python carrier. Rename executables to deepseek-harness-sdk-runtime--, update wheel/platform/build workflow discovery, and install a Python dsh console command that requires explicit DSH_HOME before exec. Include profile, bundle, preset, native addon, and shared-library assets needed by the full CLI. Remove the checked-in default cordis.yml and preserve the existing wheel distribution names, Python module names, sidecar validation, and wire identity. Runtime resolution and release tests pin the new artifacts and dev Node carrier. --- .../workflows/build-exe-for-python-sdk.yml | 2 +- .gitignore | 2 +- .gitlab-ci.yml | 2 +- pnpm-lock.yaml | 22 ++----- python/sdk-runtime/hatch_build.py | 4 +- python/sdk-runtime/package.json | 7 ++- python/sdk-runtime/platforms.json | 6 +- python/sdk-runtime/pyproject.toml | 10 +-- .../src/deepseek_harness_runtime/__init__.py | 51 +++++++-------- .../runtime/cordis.yml | 58 ----------------- python/sdk/tests/test_release_version.py | 9 ++- python/sdk/tests/test_runtime_resolution.py | 63 +++++++++++++++---- scripts/build-exe-for-python-sdk.ts | 22 ++++--- scripts/build-python-release.py | 4 +- scripts/translation-pairing.spec.ts | 2 +- scripts/translation-pairing.ts | 4 +- 16 files changed, 125 insertions(+), 143 deletions(-) delete mode 100644 python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index b9ba5e148e..04501d5deb 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -235,7 +235,7 @@ jobs: run: | set -euo pipefail platform="${TARGET#node24-}" - exe="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$platform" + exe="$PWD/dist-exe/deepseek-harness-sdk-runtime-$platform" [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } case "$platform" in linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; diff --git a/.gitignore b/.gitignore index 70c355e391..d33bbc8a4e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ tmp/ .idea mise.toml dist-exe/ -python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-* +python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-* python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ python/**/__pycache__/ python/**/.pytest_cache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cf5a093f69..faa9402c42 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -38,7 +38,7 @@ sdk-wheel: - pnpm install --frozen-lockfile - pnpm run verify-runtime-closure - pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="$PKG_TARGET" - - EXE="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$PLATFORM" + - EXE="$PWD/dist-exe/deepseek-harness-sdk-runtime-$PLATFORM" - test -x "$EXE" - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aef169a41..9deab88d6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6826,19 +6826,6 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent - packages/sdk/python-runtime: - dependencies: - '@deepseek-ai/dsh-app-boot': - specifier: workspace:^ - version: link:../../boot/app-boot - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - packages/sdk/server: dependencies: '@deepseek-ai/schemastery': @@ -9618,6 +9605,9 @@ importers: '@deepseek-ai/cosmokit': specifier: link:../../vendor/cosmokit version: link:../../vendor/cosmokit + '@deepseek-ai/dsh': + specifier: workspace:^ + version: link:../../apps/cli '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp @@ -9786,9 +9776,6 @@ importers: '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ version: link:../../packages/sdk/protocol - '@deepseek-ai/dsh-sdk-python-runtime': - specifier: workspace:^ - version: link:../../packages/sdk/python-runtime '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -9819,6 +9806,9 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../../packages/session/session-telemetry '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session/session-title diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index ef5a621f9d..c4083387a6 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -66,7 +66,9 @@ class RuntimeBuildHook(BuildHookInterface): ) expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" - runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + runtime_files = sorted( + runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] + ) expected_files = [expected_executable, f"{expected_executable}-rg"] if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index dad5db82b7..d5684867e6 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -1,6 +1,6 @@ { - "name": "dsh-sdk-python-runtime-closure", - "description": "Dependency-only deploy root defining the executable and Python runtime closure; pnpm deploy materializes this manifest and node_modules.", + "name": "dsh-python-runtime-closure", + "description": "Dependency-only deploy root defining the dsh executable shipped by the Python runtime wheel.", "version": "0.0.1", "private": true, "type": "module", @@ -10,6 +10,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/cosmokit": "workspace:^", + "@deepseek-ai/dsh": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -45,7 +46,6 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", - "@deepseek-ai/dsh-sdk-python-runtime": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/python/sdk-runtime/platforms.json b/python/sdk-runtime/platforms.json index 069378e8cb..e65cd6a735 100644 --- a/python/sdk-runtime/platforms.json +++ b/python/sdk-runtime/platforms.json @@ -1,14 +1,14 @@ { "linux-x64": { "tag": "manylinux_2_28_x86_64", - "executable": "dsh-jsonrpc-agent-pkg-linux-x64" + "executable": "deepseek-harness-sdk-runtime-linux-x64" }, "linux-arm64": { "tag": "manylinux_2_28_aarch64", - "executable": "dsh-jsonrpc-agent-pkg-linux-arm64" + "executable": "deepseek-harness-sdk-runtime-linux-arm64" }, "macos-arm64": { "tag": "macosx_14_0_arm64", - "executable": "dsh-jsonrpc-agent-pkg-macos-arm64" + "executable": "deepseek-harness-sdk-runtime-macos-arm64" } } diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index 090efc6139..4005314aa0 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "deepseek-harness-runtime-bin" version = "0.0.0.dev0" -description = "Pinned DeepSeek Harness runtime for the Python SDK" +description = "Bundled dsh CLI runtime for the DeepSeek Harness Python SDK" readme = "README.md" requires-python = ">=3.10" license = "MIT" @@ -17,10 +17,12 @@ Documentation = "https://github.com/deepseek-ai/deepseek-harness/blob/master/pyt Issues = "https://github.com/deepseek-ai/deepseek-harness/issues" Source = "https://github.com/deepseek-ai/deepseek-harness" -# Include the injected executable and default config; exclude the dev-only node -# closure from wheels and sdists. +[project.scripts] +dsh = "deepseek_harness_runtime:main" + +# Include the injected dsh executable and sidecars; exclude the dev-only node closure. [tool.hatch.build] -artifacts = ["src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*"] +artifacts = ["src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*"] exclude = ["src/deepseek_harness_runtime/runtime/node"] [tool.hatch.build.targets.wheel] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 94aca48d9f..0fc4f416c0 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -1,22 +1,21 @@ -"""Locate the bundled DeepSeek Harness SDK runtime shipped with this package. +"""Locate and execute the bundled dsh CLI shipped with the Python SDK runtime. Two runtime carriers coexist under ``runtime/``, both injected by the repo's ``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): - **exe (production)**: single-file Node executables named - ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in + ``deepseek-harness-sdk-runtime--`` (platform in {linux, macos}, arch in {x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling ``-spawn-helper``. The target machine needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node - runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`` on a + runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`` on a system Node >= 22.19. It is the current checkout's source build, never selected automatically, and excluded from wheel/sdist distributions. -``runtime/cordis.yml`` IS checked in: it is the default agent configuration -the client SDK injects via ``$DSH_CORDIS_CONFIG`` for zero-config runs — the -runtime itself always requires an explicit config and has no built-in -fallback. +Both carriers execute the same dsh command grammar. The Python SDK selects the +``sdk`` profile and requires an explicit Harness home; the installed ``dsh`` +console command requires ``DSH_HOME`` for the same reason. """ from __future__ import annotations @@ -52,21 +51,6 @@ def bundled_package_dir() -> Path: return root -def bundled_default_config_path() -> Path: - """Path of the checked-in default runtime configuration (``runtime/cordis.yml``). - - The client SDK injects this path via ``$DSH_CORDIS_CONFIG`` when the caller - supplies no config and the launch resolves to the bundled runtime — the - runtime binary itself always demands an explicit config. - """ - path = bundled_package_dir() / "runtime" / "cordis.yml" - if not path.is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the default runtime config at {path}" - ) - return path - - def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. @@ -78,7 +62,7 @@ def bundled_runtime_path() -> Path: touching callers). """ tag = _current_platform_tag() - path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" + path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}" if not path.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " @@ -128,7 +112,7 @@ def _current_platform_tag() -> str: arch = _ARCH_TAGS.get(platform.machine().lower()) if plat is None or arch is None: raise FileNotFoundError( - "no bundled dsh-jsonrpc-agent executable exists for this platform " + "no bundled DeepSeek Harness SDK runtime exists for this platform " f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " "linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT ) @@ -141,9 +125,9 @@ def _node_launch_args() -> tuple[str, str]: node_root / "node_modules" / "@deepseek-ai" - / "dsh-sdk-python-runtime" + / "dsh" / "lib" - / "packaged-bin.js" + / "bin.js" ) if not bin_js.is_file(): raise FileNotFoundError( @@ -161,11 +145,24 @@ def _node_launch_args() -> tuple[str, str]: return (node, str(bin_js)) +def main() -> None: + """Execute the bundled dsh CLI with an explicitly selected Harness home.""" + if not os.environ.get("DSH_HOME", "").strip(): + print( + "dsh: the Python runtime command requires an explicit DSH_HOME; " + "it never uses ~/.dsh implicitly", + file=sys.stderr, + ) + raise SystemExit(2) + argv = (*resolve_bundled_launch_args(), *sys.argv[1:]) + os.execvpe(argv[0], argv, os.environ) + + __all__ = [ "PACKAGE_METADATA_FILENAME", "RUNTIME_MODE_ENV_VAR", - "bundled_default_config_path", "bundled_package_dir", "bundled_runtime_path", + "main", "resolve_bundled_launch_args", ] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml deleted file mode 100644 index 02f8cac145..0000000000 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ /dev/null @@ -1,58 +0,0 @@ -# Bundled default config. The runtime still requires an explicit -# $DSH_CORDIS_CONFIG or argv path; the SDK injects this path for bundled -# zero-config launches. SDK-set session-root and cwd variables have manual-run fallbacks. - -# Stdio JSON-RPC server entry; without it the agent has no SDK client. -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' - -# Agent spine; the SDK server creates agents per sessionId. -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - workspaceContext: - maxBytes: 65536 - -# Stock DeepSeek adapters. The adapter resolves DEEPSEEK_API_KEY through the -# credential seam and, with no provider mounted here, from the launching -# environment; DEEPSEEK_BASE_URL follows the same environment ladder. Neither -# is inlined, so this file names no secret and no route. -- id: deepseek-llm-api-extensions - name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' - -- id: session-log-deepseek - name: '@deepseek-ai/dsh-session-log-deepseek' - -- id: plugin-package-inventory-deepseek - name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - -# JSONL persistence; $DSH_SESSION_ROOT wins over ./.sessions in the process cwd. -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - -# Persistence owns durable storage; this separate policy explicitly selects -# the request, tool-dispatch, and completed-step durability checkpoints. -- id: session-checkpoints - name: '@deepseek-ai/dsh-session-checkpoint-policy' - -# Local bash executor; $DSH_CWD wins over the process cwd. -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -# Local filesystem provider for workspace instruction loading. This does not -# expose model-facing file tools by itself. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index b21a485949..deaa65b8a8 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -59,6 +59,7 @@ def test_pep440_version_spells_a_prerelease_the_python_way() -> None: def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None: assert build_python_release.PLATFORMS["macos-arm64"][0] == "macosx_14_0_arm64" + assert build_python_release.PLATFORMS["macos-arm64"][1] == "deepseek-harness-sdk-runtime-macos-arm64" def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None: @@ -88,7 +89,7 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool ) -> None: - executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" + executable = tmp_path / f"deepseek-harness-sdk-runtime-{target}" executable.write_bytes(b"runtime") executable.chmod(0o755) expected = {executable.name: b"runtime"} @@ -106,10 +107,14 @@ def test_stage_runtime_copies_platform_payload( build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" - assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected + assert { + path.name: path.read_bytes() + for path in runtime_dir.glob("deepseek-harness-sdk-runtime-*") + } == expected pyproject = (destination / "pyproject.toml").read_text() assert 'license = "MIT"' in pyproject assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject + assert 'dsh = "deepseek_harness_runtime:main"' in pyproject assert (destination / "platforms.json").read_bytes() == ( ROOT / "python" / "sdk-runtime" / "platforms.json" ).read_bytes() diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 14e90f3283..fc54171b75 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -9,21 +9,12 @@ import pytest from deepseek_harness_runtime import ( RUNTIME_MODE_ENV_VAR, - bundled_default_config_path, bundled_package_dir, + main, resolve_bundled_launch_args, ) -def test_default_config_is_shipped_with_the_package() -> None: - path = bundled_default_config_path() - assert path == bundled_package_dir() / "runtime" / "cordis.yml" - config = path.read_text() - assert "@deepseek-ai/dsh-agent-spine-demo" in config - assert "@deepseek-ai/dsh-session-persistence-jsonl" in config - assert "@deepseek-ai/dsh-session-checkpoint-policy" in config - - def test_unknown_explicit_mode_fails_loud() -> None: with pytest.raises(ValueError, match="expected 'exe' or 'node'"): resolve_bundled_launch_args("bogus") @@ -49,10 +40,10 @@ def test_runtime_requires_spawn_helper_only_on_macos( ) -> None: runtime_dir = tmp_path / "runtime" runtime_dir.mkdir() - linux = runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64" + linux = runtime_dir / "deepseek-harness-sdk-runtime-linux-x64" linux.touch() Path(f"{linux}-rg").touch() - macos = runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64" + macos = runtime_dir / "deepseek-harness-sdk-runtime-macos-arm64" macos.touch() Path(f"{macos}-rg").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) @@ -69,9 +60,55 @@ def test_runtime_requires_ripgrep_sidecar( ) -> None: runtime_dir = tmp_path / "runtime" runtime_dir.mkdir() - (runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64").touch() + (runtime_dir / "deepseek-harness-sdk-runtime-linux-x64").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") with pytest.raises(FileNotFoundError, match="ripgrep sidecar"): runtime.bundled_runtime_path() + + +def test_node_mode_runs_the_deployed_dsh_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bin_js = tmp_path / "runtime" / "node" / "node_modules" / "@deepseek-ai" / "dsh" / "lib" / "bin.js" + bin_js.parent.mkdir(parents=True) + bin_js.touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime.shutil, "which", lambda _name: "/node") + + assert resolve_bundled_launch_args("node") == ("/node", str(bin_js)) + + +def test_python_dsh_command_requires_explicit_home( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.delenv("DSH_HOME", raising=False) + + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 2 + assert "explicit DSH_HOME" in capsys.readouterr().err + + +def test_python_dsh_command_executes_the_bundled_cli( + monkeypatch: pytest.MonkeyPatch +) -> None: + called: dict[str, object] = {} + monkeypatch.setenv("DSH_HOME", "/explicit/home") + monkeypatch.setattr(runtime, "resolve_bundled_launch_args", lambda: ("/runtime",)) + monkeypatch.setattr(runtime.sys, "argv", ["dsh", "plugin", "--profile", "sdk", "list"]) + + def execvpe(file: str, args: tuple[str, ...], env: dict[str, str]) -> None: + called.update(file=file, args=args, home=env.get("DSH_HOME")) + + monkeypatch.setattr(runtime.os, "execvpe", execvpe) + + main() + + assert called == { + "file": "/runtime", + "args": ("/runtime", "plugin", "--profile", "sdk", "list"), + "home": "/explicit/home", + } diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 7516fcabb6..f7d696edfe 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -1,5 +1,5 @@ /** - * Build the SDK runtime executables and Python node carrier. The fixed + * Build the dsh executables and development Node carrier for the Python runtime wheel. The fixed * `@yao-pkg/pkg --sea` route, deploy flags, and artifact layout are owned by * .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md. * The staged closure is symlink-free, and whole-tree assets cover Cordis's @@ -16,11 +16,11 @@ import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty. const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ -const DEPLOY_ROOT_PACKAGE = 'dsh-sdk-python-runtime-closure' -/** The closed-runtime app entry inside the deployed closure. */ -const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js' -/** Stable Python-visible executable basename; rename with the later Python runtime migration. */ -const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' +const DEPLOY_ROOT_PACKAGE = 'dsh-python-runtime-closure' +/** The sole application launcher inside the deployed closure. */ +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh/lib/bin.js' +/** Python-visible executable basename. */ +const OUTPUT_BASENAME = 'deepseek-harness-sdk-runtime' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' /** Pinned for reproducible builds. */ @@ -47,8 +47,15 @@ const ASSET_GLOBS = [ 'node_modules/**/*.mjs', 'node_modules/**/package.json', 'node_modules/**/*.json', + 'node_modules/**/*.md', + 'node_modules/**/*.dylib', + 'node_modules/**/*.dll', 'node_modules/**/*.node', + 'node_modules/**/*.so', + 'node_modules/**/*.so.*', 'node_modules/**/*.wasm', + 'node_modules/**/*.yaml', + 'node_modules/**/*.yml', ] const PLATFORMS = ['linux', 'macos'] as const @@ -220,8 +227,7 @@ function formatCommand(command: string, args: string[]): string { */ class SingleExeBuild { /** - * The cleared deploy target, pkg input, and Python node-mode carrier. The - * checked-in default `cordis.yml` remains in its parent directory. + * The cleared deploy target, pkg input, and Python node-mode carrier. */ readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR) private readonly outDir = resolve(root, OUT_DIR) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 0b4157f40d..c546c6bb42 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -150,7 +150,7 @@ def copy_package(source: Path, destination: Path) -> None: "*.pyc", "dist", "node_modules", - "dsh-jsonrpc-agent-pkg-*", + "deepseek-harness-sdk-runtime-*", ), ) @@ -246,7 +246,7 @@ def verify_wheel( f"{wheel} has license files {license_files}, expected {expected_license_files}" ) runtime_files = [ - name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name + name for name in archive.namelist() if "/runtime/deepseek-harness-sdk-runtime-" in name ] if package == "runtime": assert platform is not None diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index c4dda468fe..e36b19815d 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -310,7 +310,7 @@ describe('translation scope discovery', () => { 'packages/example/node_modules/dependency/README.md', 'packages/example/lib/README.md', 'coverage/report/README.md', - 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-macos-arm64/README.md', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-macos-arm64/README.md', 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md', ])('excludes non-source or non-README path %s', (file) => { expect(isTranslationScopeFile(file)).toBe(false) diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 95b70c48b1..e4c0919a2f 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -165,7 +165,7 @@ export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [ '**/.pytest_cache/**', 'apps/web/dist/**', '.artifacts/**', - 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*/**', 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**', 'vendor/**', ] @@ -177,7 +177,7 @@ function isTranslationSourceExcluded(file: string): boolean { || segment.startsWith('.doc-typecheck-') || segment.startsWith('.node-next-types-')) || file.startsWith('apps/web/dist/') - || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-') + || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-') || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/') } From 56e038b2e33dac3a57bdf14f42992860340ac1b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:16 +0800 Subject: [PATCH 061/138] feat(python-sdk): launch dsh profiles from explicit homes Replace complete-config, session_root, runtime-bin, bridge-bin, and public argv override options with dsh_bin, profile, ordered patches, and dsh_home. Resolve executable/home/patch/cwd paths before spawn, select the sdk profile by default, and fail before launch unless dsh_home or non-empty DSH_HOME is explicit; Python never inherits ~/.dsh silently. Remove Python-owned DSH_CORDIS_CONFIG, DSH_SESSION_ROOT, and DSH_CWD injection and drop session_root from RunResult. Keep arbitrary argv only as an underscore-prefixed fake-runtime adapter, retain provider/model/token and process controls, and append subprocess stderr to initialization JSON-RPC errors so profile boot failures name their actual plugin cause. Unit and carrier tests cover both exe and Node modes. --- python/sdk/src/deepseek_harness/api.py | 32 ++-- python/sdk/src/deepseek_harness/client.py | 82 +++++----- python/sdk/tests/manual_sdk_agent_smoke.py | 30 ++-- python/sdk/tests/test_bundled_runtime.py | 130 ++++++---------- python/sdk/tests/test_client.py | 166 ++++++++++++--------- 5 files changed, 225 insertions(+), 215 deletions(-) diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 9c542c012a..1b33524118 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -24,11 +24,11 @@ class DeepSeekHarnessConfig: max_tokens: int | None = None cwd: str | None = None runtime_cwd: str | None = None - session_root: str | None = None - cordis: str | None = None + dsh_bin: str | None = None + profile: str = "sdk" + patches: tuple[str, ...] = () + dsh_home: str | None = None env: dict[str, str] = field(default_factory=dict) - runtime_bin: str | None = None - launch_args_override: tuple[str, ...] | None = None request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 base_url: str | None = None @@ -42,7 +42,6 @@ class RunResult: finish_reason: str | None events: list[JsonObject] notifications: list[Notification] - session_root: str | None = None class DeepSeekHarness: @@ -53,7 +52,13 @@ class DeepSeekHarness: :meth:`close` explicitly when finished, so the subprocess is always reaped. """ - def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None: + def __init__( + self, + config: DeepSeekHarnessConfig | None = None, + *, + _launch_args: tuple[str, ...] | None = None, + **kwargs: object, + ) -> None: if config is not None and kwargs: raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both") self.config = config or DeepSeekHarnessConfig(**kwargs) @@ -61,11 +66,6 @@ class DeepSeekHarness: runtime_cwd = str(Path(self.config.runtime_cwd).resolve()) if self.config.runtime_cwd is not None else cwd self._cwd = cwd env = dict(self.config.env) - if self.config.session_root is not None: - env["DSH_SESSION_ROOT"] = self.config.session_root - if self.config.cordis is not None: - env["DSH_CORDIS_CONFIG"] = self.config.cordis - env["DSH_CWD"] = cwd if self.config.base_url is not None: env["DEEPSEEK_BASE_URL"] = self.config.base_url if self.config.api_key is not None: @@ -73,13 +73,16 @@ class DeepSeekHarness: self._client = HarnessClient( HarnessConfig( - runtime_bin=self.config.runtime_bin, - launch_args_override=self.config.launch_args_override, + dsh_bin=self.config.dsh_bin, + profile=self.config.profile, + patches=self.config.patches, + dsh_home=self.config.dsh_home, cwd=runtime_cwd, env=env, request_timeout_seconds=self.config.request_timeout_seconds, shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, - ) + ), + _launch_args=_launch_args, ) self._initialized = False @@ -179,7 +182,6 @@ class Session: finish_reason=finish_reason(events), events=events, notifications=notifications, - session_root=self.harness.config.session_root, ) diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 5442c7e144..8969bc7901 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -25,20 +25,28 @@ NotificationFilter: TypeAlias = Callable[[Notification], bool] class HarnessConfig: """Configuration for launching the local DeepSeek Harness SDK runtime.""" - runtime_bin: str | None = None - bridge_bin: str | None = None - launch_args_override: tuple[str, ...] | None = None + dsh_bin: str | None = None + profile: str = "sdk" + patches: tuple[str, ...] = () + dsh_home: str | None = None cwd: str | None = None env: dict[str, str] | None = None request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 + _launch_args: tuple[str, ...] | None = None class HarnessClient: """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio.""" - def __init__(self, config: HarnessConfig | None = None) -> None: + def __init__( + self, + config: HarnessConfig | None = None, + *, + _launch_args: tuple[str, ...] | None = None, + ) -> None: self.config = config or HarnessConfig() + self._launch_args = _launch_args or self.config._launch_args self._proc: subprocess.Popen[str] | None = None self._lock = threading.Lock() self._write_lock = threading.Lock() @@ -65,11 +73,10 @@ class HarnessClient: return with self._lock: self._session_parents.clear() - args = list(self.config.launch_args_override or self._default_launch_args()) env = os.environ.copy() if self.config.env: env.update(self.config.env) - self._inject_bundled_default_config(env) + args = list(self._launch_args or self._default_launch_args(env)) self._proc = subprocess.Popen( args, stdin=subprocess.PIPE, @@ -131,8 +138,15 @@ class HarnessClient: payload["maxTokens"] = max_tokens try: return self.request("initialize", payload, response_model=InitializeResponse) - except BaseException: + except BaseException as error: self.close() + diagnostics = self._runtime_diagnostics() + if isinstance(error, JsonRpcError) and diagnostics: + raise JsonRpcError( + error.code, + f"{error.message}\n{diagnostics}", + error.data, + ) from error raise def session_prompt( @@ -421,37 +435,35 @@ class HarnessClient: parts.append("stderr tail:\n" + "\n".join(self._stderr_lines)) return "\n".join(parts) - def _default_launch_args(self) -> tuple[str, ...]: - if self.config.runtime_bin is not None: - return (self.config.runtime_bin,) - if self.config.bridge_bin is not None: - return (self.config.bridge_bin,) - try: - from deepseek_harness_runtime import resolve_bundled_launch_args - except ImportError as exc: - raise FileNotFoundError( - "Unable to locate the bundled DeepSeek Harness SDK runtime. " - "Install deepseek-harness-runtime-bin or set HarnessConfig.runtime_bin." - ) from exc - return resolve_bundled_launch_args() + def _default_launch_args(self, env: dict[str, str]) -> tuple[str, ...]: + if self.config.dsh_bin is None: + try: + from deepseek_harness_runtime import resolve_bundled_launch_args + except ImportError as exc: + raise FileNotFoundError( + "Unable to locate the bundled DeepSeek Harness dsh runtime. " + "Install deepseek-harness-runtime-bin." + ) from exc + base = resolve_bundled_launch_args() + else: + base = (str(Path(self.config.dsh_bin).expanduser().resolve()),) - def _inject_bundled_default_config(self, env: dict[str, str]) -> None: - """Inject the default config for a bundled launch with no non-empty config. + if self.config.dsh_home is not None: + if not self.config.dsh_home.strip(): + raise ValueError("HarnessConfig requires a non-empty dsh_home") + env["DSH_HOME"] = str(Path(self.config.dsh_home).expanduser().resolve()) + elif not env.get("DSH_HOME", "").strip(): + raise ValueError( + "HarnessConfig requires an explicit dsh_home or non-empty DSH_HOME; " + "the Python SDK never uses ~/.dsh implicitly" + ) - Both bundled carriers require an explicit config. Explicit runtime, - launch-argument, and config channels remain untouched. - """ - uses_bundled_runtime = ( - self.config.launch_args_override is None - and self.config.runtime_bin is None - and self.config.bridge_bin is None + patches = tuple( + argument + for patch in self.config.patches + for argument in ("--patch", str(Path(patch).expanduser().resolve())) ) - if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"): - return - # _default_launch_args already imported the package or raised its install error. - from deepseek_harness_runtime import bundled_default_config_path - - env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) + return (*base, "--profile", self.config.profile, *patches) def _unsubscribe_notifications(self, subscription_id: str) -> None: with self._lock: diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index c0305d9211..acadb495be 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -1,4 +1,4 @@ -"""Drive the repo-source JSON-RPC bin through the SDK and a keyless mock SSE server. +"""Drive the repo-source dsh SDK profile through the SDK and a keyless mock SSE server. Requires ``pnpm install`` but no build. This manual test is not collected by pytest; run ``python tests/manual_sdk_agent_smoke.py``. @@ -16,7 +16,6 @@ from pathlib import Path from typing import Any from deepseek_harness import DeepSeekHarness -from deepseek_harness_runtime import bundled_default_config_path class MockCompletionHandler(BaseHTTPRequestHandler): @@ -43,15 +42,16 @@ class MockCompletionHandler(BaseHTTPRequestHandler): def run_smoke(repo_root: Path, keep_sessions: bool) -> None: - session_root = Path(tempfile.mkdtemp(prefix="dsh-sdk-smoke-sessions-")) - runtime_entry = repo_root / "packages/sdk/python-runtime/src/packaged-bin.ts" + dsh_home = Path(tempfile.mkdtemp(prefix="dsh-sdk-smoke-home-")) + session_root = dsh_home / "sessions" + runtime_entry = repo_root / "apps/cli/src/bin.ts" server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) thread.start() base_url = f"http://127.0.0.1:{server.server_address[1]}" print(f"repo_root={repo_root}") - print(f"session_root={session_root}") + print(f"dsh_home={dsh_home}") print(f"mock_base_url={base_url}") try: @@ -59,10 +59,18 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: model="sdk-smoke-model", cwd=str(repo_root / "python/sdk"), runtime_cwd=str(repo_root), - session_root=str(session_root), - cordis=str(bundled_default_config_path()), - launch_args_override=("node", "--import", "tsx", str(runtime_entry)), + _launch_args=( + "node", + "--import", + "tsx", + str(runtime_entry), + "--profile", + "sdk", + ), env={ + "DSH_HOME": str(dsh_home), + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", "DEEPSEEK_BASE_URL": base_url, "DEEPSEEK_API_KEY": "sdk-smoke-key", }, @@ -92,10 +100,10 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: server.server_close() if keep_sessions: - print(f"kept_session_root={session_root}") + print(f"kept_dsh_home={dsh_home}") else: - shutil.rmtree(session_root) - print("removed temporary session root") + shutil.rmtree(dsh_home) + print("removed temporary dsh home") def main() -> None: diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 52d84cd161..3da5247795 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -1,4 +1,4 @@ -"""Keyless boot tests for the production exe and development node carrier. +"""Keyless boot tests for the production exe and development dsh carrier. Each carrier skips independently when absent. The dummy API key only satisfies adapter loading; initialize and shutdown do not call a model. @@ -6,64 +6,39 @@ adapter loading; initialize and shutdown do not call a model. from __future__ import annotations +import json from pathlib import Path import pytest from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig -from deepseek_harness.errors import TransportClosedError -from deepseek_harness_runtime import resolve_bundled_launch_args +from deepseek_harness.errors import JsonRpcError, TransportClosedError +from deepseek_harness_runtime import RUNTIME_MODE_ENV_VAR, resolve_bundled_launch_args _MODES = ("exe", "node") -_REPO_ROOT = Path(__file__).parents[3] -_MINIMAL_CONFIG = _REPO_ROOT / "examples" / "python-sdk-agent" / "minimal.cordis.yml" - -# The config must include the JSON-RPC serving plugin. -_CORDIS_YML = """\ -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - workspaceContext: false -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './sessions' -- id: session-checkpoints - name: '@deepseek-ai/dsh-session-checkpoint-policy' -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - cwd: '.' -- id: todo - name: '@deepseek-ai/dsh-tool-todo' - config: - allowParallelInProgress: true -""" -def _launch_args(mode: str) -> tuple[str, ...]: +def _select_mode(mode: str, monkeypatch: pytest.MonkeyPatch) -> None: try: - return resolve_bundled_launch_args(mode) + resolve_bundled_launch_args(mode) except FileNotFoundError as exc: pytest.skip(f"bundled {mode}-mode runtime unavailable on this machine: {exc}") + monkeypatch.setenv(RUNTIME_MODE_ENV_VAR, mode) -def _client(tmp_path: Path, launch_args: tuple[str, ...]) -> HarnessClient: +def _client(tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch, *patches: Path) -> HarnessClient: + _select_mode(mode, monkeypatch) return HarnessClient( HarnessConfig( - launch_args_override=launch_args, + dsh_home=str(tmp_path / "home"), + patches=tuple(str(patch) for patch in patches), cwd=str(tmp_path), env={ - "DSH_CORDIS_CONFIG": "./cordis.yml", - "DSH_SESSION_ROOT": str(tmp_path / "sessions"), - "DSH_CWD": str(tmp_path), # The lazily mounted adapter requires a key even without a model call. "DEEPSEEK_API_KEY": "sk-dummy-for-boot", "DEEPSEEK_BASE_URL": "http://127.0.0.1:9", + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", }, request_timeout_seconds=120, ) @@ -71,34 +46,39 @@ def _client(tmp_path: Path, launch_args: tuple[str, ...]) -> HarnessClient: @pytest.mark.parametrize("mode", _MODES) -def test_bundled_runtime_boots_a_cordis_config(tmp_path: Path, mode: str) -> None: - launch_args = _launch_args(mode) - (tmp_path / "cordis.yml").write_text(_CORDIS_YML) - - with _client(tmp_path, launch_args) as client: +def test_bundled_runtime_boots_the_sdk_profile( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + with _client(tmp_path, mode, monkeypatch) as client: init = client.initialize(provider="deepseek-official", cwd=str(tmp_path), model="deepseek-v4-pro") assert init.serverInfo is not None assert init.serverInfo.name == "deepseek-harness-sdk-runtime" + profile = json.loads((tmp_path / "home" / "profiles" / "sdk" / "package.json").read_text()) + assert profile["dsh"]["profile"]["bundles"] == [ + "@deepseek-ai/dsh-base", + "@deepseek-ai/dsh-sdk-app", + ] @pytest.mark.parametrize("mode", _MODES) -def test_python_sdk_boots_minimal_jsonrpc_config(tmp_path: Path, mode: str) -> None: - launch_args = _launch_args(mode) - model = "minimal-environment-model" +def test_python_sdk_applies_an_ordered_profile_patch( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + _select_mode(mode, monkeypatch) + patch = tmp_path / "persona.patch.yml" + patch.write_text(json.dumps([{ + "id": "system-prompt", + "config": {"persona": "Python SDK ordered patch marker."}, + }])) harness = DeepSeekHarness( - model=model, + model="deepseek-v4-pro", cwd=str(tmp_path), - session_root=str(tmp_path / "sessions"), - cordis=str(_MINIMAL_CONFIG), - env={ - "DSH_MODEL": model, - "DSH_CONTEXT_WINDOW": "1000000", - "DSH_SYSTEM_PROMPT": "You are the Python SDK minimal boot test agent.", - }, + dsh_home=str(tmp_path / "home"), + patches=(str(patch),), + env={"DSH_PERMISSION_MODE": "danger-full-access"}, api_key="sk-dummy-for-boot", base_url="http://127.0.0.1:9", - launch_args_override=launch_args, request_timeout_seconds=120, ) @@ -107,42 +87,20 @@ def test_python_sdk_boots_minimal_jsonrpc_config(tmp_path: Path, mode: str) -> N @pytest.mark.parametrize("mode", _MODES) -def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode: str) -> None: - launch_args = _launch_args(mode) - (tmp_path / "cordis.yml").write_text( - "- id: missing\n name: '@deepseek-ai/dsh-does-not-exist'\n" - ) +def test_bundled_runtime_surfaces_unbundled_plugin_failure( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + patch = tmp_path / "missing.patch.yml" + patch.write_text(json.dumps([{ + "insert": [{"id": "missing", "name": "@deepseek-ai/dsh-does-not-exist"}], + }])) - client = _client(tmp_path, launch_args) + client = _client(tmp_path, mode, monkeypatch, patch) client.start() try: - with pytest.raises((TransportClosedError, TimeoutError)) as excinfo: + with pytest.raises((JsonRpcError, TransportClosedError, TimeoutError)) as excinfo: client.initialize(provider="deepseek-official", cwd=str(tmp_path), model="deepseek-v4-pro") finally: client.close() assert "@deepseek-ai/dsh-does-not-exist" in str(excinfo.value) - - -@pytest.mark.parametrize("mode", _MODES) -@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) -def test_zero_config_run_injects_bundled_default_cordis_config( - tmp_path: Path, mode: str, ambient_config: str | None, monkeypatch: pytest.MonkeyPatch -) -> None: - _launch_args(mode) # skip early when this carrier is unavailable - monkeypatch.setenv("DSH_RUNTIME_MODE", mode) - if ambient_config is None: - monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) - else: - monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) - - harness = DeepSeekHarness( - model="deepseek-v4-pro", - cwd=str(tmp_path), - session_root=str(tmp_path / "sessions"), - api_key="sk-dummy-for-boot", - base_url="http://127.0.0.1:9", - request_timeout_seconds=120, - ) - with harness: - pass diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 51c9dacb31..f1190407c9 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -9,7 +9,8 @@ from pathlib import Path import pytest -from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification, SdkProtocolError +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification, RunResult, SdkProtocolError +from deepseek_harness.errors import JsonRpcError def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None: @@ -95,9 +96,7 @@ for line in sys.stdin: model="deepseek-v4-flash", max_tokens=4096, cwd=str(tmp_path), - cordis=str(tmp_path / "cordis.yml"), - session_root=str(tmp_path / "sessions"), - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), env={ "ENV_DUMP": str(env_dump), "INIT_DUMP": str(init_dump), @@ -113,9 +112,9 @@ for line in sys.stdin: dumped_env = json.loads(env_dump.read_text()) assert dumped_env["DEEPSEEK_API_KEY"] == "env-key" assert dumped_env["DEEPSEEK_BASE_URL"] == "http://127.0.0.1:4321" - assert dumped_env["DSH_CWD"] == str(tmp_path) - assert dumped_env["DSH_SESSION_ROOT"] == str(tmp_path / "sessions") - assert dumped_env["DSH_CORDIS_CONFIG"] == str(tmp_path / "cordis.yml") + assert dumped_env["DSH_CWD"] is None + assert dumped_env["DSH_SESSION_ROOT"] is None + assert dumped_env["DSH_CORDIS_CONFIG"] is None assert json.loads(init_dump.read_text()) == { "cwd": str(tmp_path), "provider": "deepseek-official", @@ -150,7 +149,7 @@ for line in sys.stdin: seen: list[str] = [] with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: session = harness.start_session("main") @@ -188,7 +187,7 @@ for line in sys.stdin: ) with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: with pytest.raises( @@ -224,7 +223,7 @@ for line in sys.stdin: with DeepSeekHarness( cwd=".", runtime_cwd=".", - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), env={"CAPTURE": str(capture)}, ): pass @@ -232,7 +231,7 @@ for line in sys.stdin: expected = str(tmp_path.resolve()) assert json.loads(capture.read_text()) == { "process": expected, - "environment": expected, + "environment": None, "wire": expected, } @@ -263,7 +262,7 @@ for line in sys.stdin: ) with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: result = harness.run("spawn a helper", session_id="main") @@ -312,7 +311,7 @@ for line in sys.stdin: seen: list[str] = [] with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: result = harness.run( @@ -367,7 +366,7 @@ for line in sys.stdin: ) with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: result = harness.run("stay in your lane", session_id="main") @@ -401,7 +400,7 @@ for line in sys.stdin: """.strip() ) - with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: result = harness.run("one turn", session_id="main") assert harness.client._notifications.qsize() == 0 @@ -441,7 +440,7 @@ for line in sys.stdin: """.strip() ) - with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: first = harness.run("first turn", session_id="main") second = harness.run("second turn", session_id="main") @@ -473,7 +472,7 @@ for line in sys.stdin: ) with HarnessClient( - HarnessConfig(launch_args_override=(sys.executable, str(script))) + HarnessConfig(_launch_args=(sys.executable, str(script))) ) as client: init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" @@ -612,7 +611,7 @@ for line in sys.stdin: def broken_filter(_notification: object) -> bool: raise RuntimeError("bad notification filter") - with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") with ( client.subscribe_notifications(broken_filter) as broken, @@ -649,7 +648,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") with pytest.raises(ValueError): client.session_prompt("main", [{"type": "text", "text": "fix it"}]) @@ -677,7 +676,7 @@ for line in sys.stdin: ) with HarnessClient( - HarnessConfig(launch_args_override=(sys.executable, str(script))) + HarnessConfig(_launch_args=(sys.executable, str(script))) ) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") @@ -711,7 +710,7 @@ for line in sys.stdin: ) with HarnessClient( - HarnessConfig(launch_args_override=(sys.executable, str(script))) + HarnessConfig(_launch_args=(sys.executable, str(script))) ) as client: init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" @@ -731,7 +730,7 @@ time.sleep(60) with HarnessClient( HarnessConfig( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), request_timeout_seconds=0.1, ) ) as client: @@ -767,7 +766,7 @@ for line in sys.stdin: client = HarnessClient( HarnessConfig( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), shutdown_timeout_seconds=0.1, ) ) @@ -792,6 +791,7 @@ import sys for line in sys.stdin: msg = json.loads(line) if msg.get("method") == "initialize": + print("initialize diagnostic", file=sys.stderr, flush=True) print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32000, "message": "bad initialize"}}), flush=True) elif msg.get("method") == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) @@ -799,14 +799,16 @@ for line in sys.stdin: """.strip() ) - client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) client.start() proc = client._proc assert proc is not None - with pytest.raises(Exception, match="bad initialize"): + with pytest.raises(JsonRpcError, match="bad initialize") as excinfo: client.initialize(provider="deepseek-official", cwd=".", model="dsagent") + assert excinfo.value.code == -32000 + assert "initialize diagnostic" in str(excinfo.value) assert proc.wait(timeout=1) is not None assert client._proc is None @@ -824,6 +826,16 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None: assert "max_tokens" in inspect.signature(HarnessClient.initialize).parameters assert "client_name" not in HarnessConfig.__dataclass_fields__ assert "client_version" not in HarnessConfig.__dataclass_fields__ + assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set( + DeepSeekHarnessConfig.__dataclass_fields__ + ) + assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set( + HarnessConfig.__dataclass_fields__ + ) + for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"): + assert removed not in DeepSeekHarnessConfig.__dataclass_fields__ + assert removed not in HarnessConfig.__dataclass_fields__ + assert "session_root" not in RunResult.__dataclass_fields__ def test_client_close_is_idempotent_before_and_after_start(tmp_path: Path) -> None: @@ -845,7 +857,7 @@ for line in sys.stdin: """.strip() ) - client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) client.start() client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") client.close() @@ -865,7 +877,7 @@ sys.exit(42) with HarnessClient( HarnessConfig( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), request_timeout_seconds=2, ) ) as client: @@ -897,7 +909,7 @@ with open(os.environ["SEEN"], "w") as seen: with HarnessClient( HarnessConfig( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), env={"SEEN": str(output)}, ) ) as client: @@ -915,21 +927,22 @@ with open(os.environ["SEEN"], "w") as seen: json.loads(line) -def _install_fake_bundled_runtime( +def _install_fake_bundled_dsh( tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> Path: - """Install a fake runtime package that records config and serves lifecycle calls. - - Returns the fake bundled default config path. - """ - runtime = tmp_path / "dsh-jsonrpc-agent" +) -> None: + """Install a fake runtime package that records dsh argv and serves lifecycle calls.""" + runtime = tmp_path / "dsh.py" runtime.write_text( - """#!/usr/bin/env python3 + """ import json import os import sys -json.dump({"DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG")}, open(os.environ["ENV_DUMP"], "w")) +json.dump({ + "argv": sys.argv[1:], + "DSH_HOME": os.environ.get("DSH_HOME"), + "DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG"), +}, open(os.environ["ENV_DUMP"], "w")) for line in sys.stdin: msg = json.loads(line) if msg.get("method") == "initialize": @@ -939,58 +952,75 @@ for line in sys.stdin: break """.strip() ) - runtime.chmod(0o755) - default_config = tmp_path / "default-cordis.yml" module_dir = tmp_path / "deepseek_harness_runtime" module_dir.mkdir() (module_dir / "__init__.py").write_text( f""" def resolve_bundled_launch_args(mode=None): - return ({str(runtime)!r},) - - -def bundled_default_config_path(): - return {str(default_config)!r} + return ({sys.executable!r}, {str(runtime)!r}) """.strip() ) monkeypatch.syspath_prepend(str(tmp_path)) monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) - return default_config -@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) -def test_client_default_launch_uses_bundled_runtime_and_injects_default_config( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ambient_config: str | None -) -> None: - env_dump = tmp_path / "env.json" - default_config = _install_fake_bundled_runtime(tmp_path, monkeypatch) - if ambient_config is None: - monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) - else: - monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) - - with HarnessClient(HarnessConfig(env={"ENV_DUMP": str(env_dump)})) as client: - init = client.initialize(provider="deepseek-official", cwd="/workspace", model="deepseek-v4-pro") - - assert init.serverInfo.name == "bundled-runtime" - assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == str(default_config) - - -def test_client_respects_explicit_config_over_bundled_default( +def test_client_default_launch_uses_bundled_dsh_sdk_profile_and_explicit_home( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: env_dump = tmp_path / "env.json" - _install_fake_bundled_runtime(tmp_path, monkeypatch) + home = tmp_path / "home" + patch = tmp_path / "sdk.patch.yml" + patch.write_text("[]\n") + _install_fake_bundled_dsh(tmp_path, monkeypatch) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("DSH_HOME", str(tmp_path / "ambient-home")) monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + with HarnessClient(HarnessConfig( + profile="sdk", + patches=("sdk.patch.yml",), + dsh_home=str(home), + env={"ENV_DUMP": str(env_dump), "DSH_HOME": str(tmp_path / "env-home")}, + )) as client: + init = client.initialize(provider="deepseek-official", cwd="/workspace", model="deepseek-v4-pro") + + assert init.serverInfo.name == "bundled-runtime" + assert json.loads(env_dump.read_text()) == { + "argv": ["--profile", "sdk", "--patch", str(patch)], + "DSH_HOME": str(home), + "DSH_CORDIS_CONFIG": None, + } + + +def test_client_accepts_explicit_environment_dsh_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + env_dump = tmp_path / "env.json" + home = tmp_path / "environment-home" + _install_fake_bundled_dsh(tmp_path, monkeypatch) + with HarnessClient( - HarnessConfig(env={"ENV_DUMP": str(env_dump), "DSH_CORDIS_CONFIG": "./explicit.yml"}) + HarnessConfig(profile="custom", env={"ENV_DUMP": str(env_dump), "DSH_HOME": str(home)}) ) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="deepseek-v4-pro") - assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == "./explicit.yml" + assert json.loads(env_dump.read_text()) == { + "argv": ["--profile", "custom"], + "DSH_HOME": str(home), + "DSH_CORDIS_CONFIG": None, + } + + +def test_client_rejects_an_implicit_default_dsh_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_bundled_dsh(tmp_path, monkeypatch) + monkeypatch.delenv("DSH_HOME", raising=False) + + with pytest.raises(ValueError, match="explicit dsh_home or non-empty DSH_HOME"): + HarnessClient(HarnessConfig(env={})).start() def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None: @@ -998,4 +1028,4 @@ def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.M monkeypatch.setattr(sys, "path", []) with pytest.raises(FileNotFoundError, match="Install deepseek-harness-runtime-bin"): - HarnessClient().start() + HarnessClient(HarnessConfig(dsh_home="/explicit/home")).start() From 01da0437378596c640fd74e34e37fb83e75e8334 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:34 +0800 Subject: [PATCH 062/138] test(python): prove installed dsh profile customization Migrate the packaged-runtime smoke inventory from complete Cordis trees to the sdk profile plus ordered patches. Preserve the focused minimal and advanced behaviors, update the generated durable snapshots for explicit permission events and the smaller RunResult, and keep worker, MCP, ripgrep, PTY/editor, direct JSON-RPC, and real-provider coverage. Add an installed-only external bundle scenario that invokes the wheel's dsh plugin command with a local file package, verifies profile manifest reconciliation, imports @deepseek-ai/cordis as a peer, asserts the packaged proxy returns the exact host Context instance, and proves its system-prompt contribution reaches the model. Migrate the repository source e2e and runnable minimal example to the same profile grammar. --- examples/python-sdk-agent/minimal.patch.yml | 68 ++ examples/python-sdk-agent/minimal.py | 19 +- .../tests/keyless-smoke.e2e.ts | 72 +- .../python-sdk-agent/tests/keyless.patch.yml | 38 + scripts/smoke-python-runtime.py | 510 ++++++++--- .../advanced/result.json | 813 ++++++++++-------- .../advanced/session.1.jsonl | 11 +- .../advanced/session.2.jsonl | 11 +- .../advanced/session.jsonl | 46 +- .../restart/requests.json | 8 + .../python-sdk-single-exe/restart/result.json | 94 +- .../restart/session.1.jsonl | 10 +- .../restart/session.2.jsonl | 10 +- 13 files changed, 1101 insertions(+), 609 deletions(-) create mode 100644 examples/python-sdk-agent/minimal.patch.yml create mode 100644 examples/python-sdk-agent/tests/keyless.patch.yml diff --git a/examples/python-sdk-agent/minimal.patch.yml b/examples/python-sdk-agent/minimal.patch.yml new file mode 100644 index 0000000000..be3f292cc0 --- /dev/null +++ b/examples/python-sdk-agent/minimal.patch.yml @@ -0,0 +1,68 @@ +# Minimal Python SDK overlay for `dsh --profile sdk`: keep only persistent +# Bash and the string-replacement editor, with no runtime-context prompt or +# compaction. The profile still owns JSON-RPC serving and persistence. + +- id: system-prompt + config: + includeHarnessIdentity: false + includeRuntimeContext: false + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' + +- id: agent-instructions + disabled: true +- id: skill-filesystem + disabled: true +- id: tool-skill + disabled: true +- id: tool-bash + disabled: true +- id: tool-jobs + disabled: true +- id: tool-fs + disabled: true +- id: tool-fs-search + disabled: true +- id: tool-subagent-control + disabled: true +- id: tool-subagent-list-agents + disabled: true +- id: tool-subagent + disabled: true +- id: tool-subagent-fork + disabled: true +- id: tool-subagent-report + disabled: true +- id: tool-workflow + disabled: true +- id: tool-todo + disabled: true +- id: tool-goal + disabled: true +- id: tool-ralph + disabled: true +- id: tool-web + disabled: true +- id: plan-mode + disabled: true +- id: compaction-basic + disabled: true +- id: command-compact + disabled: true +- id: tool-result-pruner + disabled: true + +- id: tool-str-replace-editor + config: + maxOutputChars: 16000 + +- insert: + - id: pty + name: '@deepseek-ai/dsh-terminal' + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + config: + timeoutMs: 300000 + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 diff --git a/examples/python-sdk-agent/minimal.py b/examples/python-sdk-agent/minimal.py index e94b02b7d8..255a0dd3c0 100644 --- a/examples/python-sdk-agent/minimal.py +++ b/examples/python-sdk-agent/minimal.py @@ -10,30 +10,39 @@ from pathlib import Path from deepseek_harness import DeepSeekHarness -CONFIG = Path(__file__).with_name("minimal.cordis.yml") +PATCH = Path(__file__).with_name("minimal.patch.yml") def main() -> None: """Parse one task and print the agent's final response.""" parser = argparse.ArgumentParser() + configured_home = os.environ.get("DSH_HOME", "") parser.add_argument("prompt", help="Task for the minimal agent") parser.add_argument("--workspace", type=Path, default=Path.cwd()) - parser.add_argument("--session-root", type=Path, default=Path(".dsh-sessions")) + parser.add_argument( + "--dsh-home", + type=Path, + default=Path(configured_home) if configured_home.strip() else None, + ) + parser.add_argument("--profile", default="sdk") parser.add_argument("--session-id") parser.add_argument("--provider", default="deepseek-official") parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash")) parser.add_argument("--max-tokens", type=int) args = parser.parse_args() + if args.dsh_home is None: + parser.error("--dsh-home or a non-empty DSH_HOME is required") workspace = args.workspace.resolve() - session_root = args.session_root.resolve() + dsh_home = args.dsh_home.resolve() with DeepSeekHarness( provider=args.provider, model=args.model, max_tokens=args.max_tokens, cwd=str(workspace), - session_root=str(session_root), - cordis=str(CONFIG.resolve()), + dsh_home=str(dsh_home), + profile=args.profile, + patches=(str(PATCH.resolve()),), ) as harness: result = harness.run(args.prompt, session_id=args.session_id) print(result.final_response) diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 99897079ef..c0aabb48b8 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -8,8 +8,8 @@ import { zstdDecompress } from 'node:zlib' import { execa } from 'execa' import { describe, expect, it } from 'vitest' -const binScript = fileURLToPath(new URL('../../../packages/sdk/python-runtime/src/packaged-bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) +const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -45,7 +45,7 @@ function waitForLine( }) } -describe('Python SDK runtime carrier keyless smoke', () => { +describe('Python SDK dsh profile keyless smoke', () => { it.each([ { label: 'reports max-token turns with the default mapping config', envValue: undefined }, { label: 'reports max-token turns with mapping enabled through env', envValue: 'true' }, @@ -73,16 +73,20 @@ describe('Python SDK runtime carrier keyless smoke', () => { // execa owns spawn, the deadline, and exit settlement around it. const child = execa(process.execPath, [ '--import', - 'tsx', + 'tsx/esm', binScript, - configPath, + '--profile', + 'sdk', + '--patch', + patchPath, ], { cwd: repoRoot, env: { + DSH_HOME: join(root, '.dsh'), + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - DSH_CWD: root, - DSH_SESSION_ROOT: join(root, '.sessions'), ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), }, timeout: 35_000, @@ -149,6 +153,7 @@ describe('Python SDK runtime carrier keyless smoke', () => { 'bash', 'edit', 'read', + 'read_image', 'subagent', 'todo_write', 'write', @@ -159,7 +164,7 @@ describe('Python SDK runtime carrier keyless smoke', () => { expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) const exit = await child expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) - const sessionsRoot = join(root, '.sessions') + const sessionsRoot = join(root, '.dsh', 'sessions') const files = await readdir(sessionsRoot, { recursive: true }) const log = files.find(file => file.endsWith('.jsonl.zstd')) expect(log).toBeDefined() @@ -176,27 +181,36 @@ describe('Python SDK runtime carrier keyless smoke', () => { }, 40_000) it('rejects an invalid max-token success env value', async () => { - const { exitCode, stdout, stderr } = await execa(process.execPath, [ - '--import', - 'tsx', - binScript, - configPath, - ], { - cwd: repoRoot, - env: { - DEEPSEEK_API_KEY: 'keyless-smoke-no-call', - DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', - }, - stdin: 'ignore', - timeout: 25_000, - killSignal: 'SIGKILL', - reject: false, - }) + const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-')) + try { + const { exitCode, stdout, stderr } = await execa(process.execPath, [ + '--import', + 'tsx/esm', + binScript, + '--profile', + 'sdk', + '--patch', + patchPath, + ], { + cwd: repoRoot, + env: { + DSH_HOME: join(root, '.dsh'), + DEEPSEEK_API_KEY: 'keyless-smoke-no-call', + DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', + }, + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, + }) - expect(exitCode, stderr).toBe(1) - expect(stdout).toBe('') - expect(stderr).toContain('plugin tree failed to load') - expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)') - expect(stderr).toContain('sometimes') + expect(exitCode, stderr).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('plugin tree failed to load') + expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)') + expect(stderr).toContain('sometimes') + } finally { + await rm(root, { recursive: true, force: true }) + } }, 30_000) }) diff --git a/examples/python-sdk-agent/tests/keyless.patch.yml b/examples/python-sdk-agent/tests/keyless.patch.yml new file mode 100644 index 0000000000..71b5ec6ebd --- /dev/null +++ b/examples/python-sdk-agent/tests/keyless.patch.yml @@ -0,0 +1,38 @@ +# Preserve the focused SDK test roster over the shipped sdk profile. + +- id: agent-instructions + disabled: true +- id: tool-jobs + disabled: true +- id: tool-fs-search + disabled: true +- id: skill-filesystem + disabled: true +- id: tool-skill + disabled: true +- id: tool-str-replace-editor + disabled: true +- id: tool-subagent-control + disabled: true +- id: tool-subagent-list-agents + disabled: true +- id: tool-subagent-fork + disabled: true +- id: tool-subagent-report + disabled: true +- id: tool-workflow + disabled: true +- id: tool-goal + disabled: true +- id: plan-mode + disabled: true +- id: tool-ralph + disabled: true +- id: tool-web + disabled: true + +- id: tool-subagent + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 71e7100268..06653c5741 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -12,6 +12,7 @@ import os import queue import subprocess import sys +import sysconfig import tempfile import threading import time @@ -37,14 +38,43 @@ FS_SEARCH_TEXT = "filesystem search smoke ok" FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" MCP_PROMPT = "Exercise the packaged MCP client with one external stdio server." MCP_TEXT = "MCP client smoke ok" -MINIMAL_CORDIS = ( - Path(__file__).resolve().parent.parent / "examples" / "python-sdk-agent" / "minimal.cordis.yml" -) +PROFILE_PLUGIN_PROMPT = "Verify the Python-installed dsh profile plugin." +PROFILE_PLUGIN_TEXT = "profile plugin smoke ok" +PROFILE_PLUGIN_MARKER = "PYTHON_INSTALLED_DSH_PROFILE_PLUGIN" MINIMAL_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" ) +MINIMAL_BASH_DESCRIPTION = """Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.""" +LEGACY_CUSTOM_DISABLED_ROWS = ( + "agent-instructions", + "goal", + "goal-round-driver", + "command-goal", + "plan-mode", + "skill", + "skill-filesystem", + "tool-fs", + "tool-fs-search", + "tool-goal", + "tool-ralph", + "tool-skill", + "tool-str-replace-editor", + "tool-subagent-control", + "tool-subagent-list-agents", + "tool-subagent-fork", + "tool-subagent-report", + "tool-todo", + "tool-web", +) SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario." SNAPSHOT_SESSION_ID = "advanced-executable" SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else." @@ -95,75 +125,6 @@ RESTART_SNAPSHOT_FILENAMES = ("result.json", "requests.json", "session.1.jsonl", # expected output cannot carry: the same composition emits it on macOS and not on Linux # (deepseek-harness#2488), and the file must replay on both. Everything else is compared. RUNTIME_CONTEXT_PREFIX = "Current runtime context" -CUSTOM_CORDIS = """\ -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' -- id: deepseek-llm-api-extensions - name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' -- id: session-log-deepseek - name: '@deepseek-ai/dsh-session-log-deepseek' - config: - enabled: true -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - workspaceContext: false - skills: - enabled: false - toolBash: false - tools: - mode: both -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT - compression: 'none' -- id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker-thread' -- id: subagents - name: '@deepseek-ai/dsh-subagent' -- id: subagent-spawn-in-process - name: '@deepseek-ai/dsh-subagent-spawn-in-process' - config: - providerName: spawn -- id: subagent-tool - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn -- id: workflow-engine - name: '@deepseek-ai/dsh-workflow-worker-thread' - config: - provider: spawn -- id: workflow-tool - name: '@deepseek-ai/dsh-tool-workflow' -- id: cordis-host-runner - name: '@deepseek-ai/dsh-cordis-host-runner' -- id: cordis-tool - name: '@deepseek-ai/dsh-tool-cordis' -""" -FS_SEARCH_CORDIS = """\ -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - workspaceContext: false - skills: - enabled: false - toolBash: false - toolJobs: false -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT - compression: 'none' -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' -- id: fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - config: - sampleOverCapGlobResults: false -""" MCP_SERVER_SCRIPT = """\ import json import os @@ -242,28 +203,29 @@ for line in sys.stdin: """ -def mcp_cordis(server_script: Path) -> str: - """Build an external config that mounts the packaged MCP client.""" - return json.dumps([ +def write_profile_patch( + root: Path, + name: str, + sessions: Path, + patches: list[dict[str, object]], +) -> Path: + """Write one JSON-form dsh profile patch with deterministic persistence.""" + path = root / name + path.write_text(json.dumps([ { - "id": "sdk-jsonrpc-server", - "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", + "id": "session-persistence-jsonl", + "config": {"root": str(sessions), "compression": "none"}, }, - { - "id": "agent-core", - "name": "@deepseek-ai/dsh-agent-spine-demo", - "config": { - "workspaceContext": False, - "skills": {"enabled": False}, - "toolBash": False, - }, - }, - { - "id": "sessions", - "name": "@deepseek-ai/dsh-session-persistence-jsonl", - "config": {"root": "./sessions", "compression": "none"}, - }, - { + {"id": "session-telemetry-otel", "disabled": True}, + *patches, + ], indent=2)) + return path + + +def write_mcp_patch(root: Path, sessions: Path, server_script: Path) -> Path: + """Write a profile patch that mounts the packaged MCP client.""" + return write_profile_patch(root, "mcp.patch.yml", sessions, [{ + "insert": [{ "id": "mcp-fixture", "name": "@deepseek-ai/dsh-mcp-client", "config": { @@ -275,8 +237,8 @@ def mcp_cordis(server_script: Path) -> str: "failOnStartupError": True, "reconnect": {"enabled": False}, }, - }, - ], indent=2) + }], + }]) class MockModelHandler(BaseHTTPRequestHandler): @@ -364,6 +326,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: MCP_PROMPT, RESTART_FIRST_PROMPT, RESTART_SECOND_PROMPT, + PROFILE_PLUGIN_PROMPT, } prompt = next( (candidate for candidate in user_prompts if candidate in scenario_prompts), @@ -430,6 +393,15 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: "mcp__fixture__add", {"a": 19, "b": 23}, ) + if prompt == PROFILE_PLUGIN_PROMPT: + system_text = "\n".join( + message_text(message.get("content")) + for message in messages + if isinstance(message, dict) and message.get("role") == "system" + ) + if PROFILE_PLUGIN_MARKER not in system_text: + raise AssertionError("external profile plugin contributed no model-visible marker") + return text_chunks(PROFILE_PLUGIN_TEXT) return text_chunks(EXPECTED_TEXT) @@ -710,7 +682,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-live", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-live", "direct"), default="all", ) parser.add_argument("--exe", type=Path) @@ -725,6 +697,8 @@ def main() -> None: parser.error("--installed-wheel resolves the wheel's own runtime and cannot be combined with --exe") if args.scenario == "sdk-live" and not args.installed_wheel: parser.error("--scenario sdk-live requires --installed-wheel") + if args.scenario == "sdk-profile-plugin" and not args.installed_wheel: + parser.error("--scenario sdk-profile-plugin requires --installed-wheel") if args.installed_wheel: args.exe = assert_installed_wheel_environment() if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "sdk-restart", "direct"} and args.exe is None: @@ -759,6 +733,8 @@ def main() -> None: if args.scenario in {"all", "sdk-restart"}: assert args.exe is not None smoke_sdk_restart_snapshot(model.url, args.exe.resolve(), args.update_snapshots) + if args.installed_wheel and args.scenario in {"all", "sdk-profile-plugin"}: + smoke_sdk_profile_plugin(model.url) if args.scenario in {"all", "direct"}: assert args.exe is not None smoke_direct(model.url, args.exe.resolve()) @@ -832,7 +808,8 @@ def smoke_sdk_live() -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-live-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" + dsh_home = root / "home" + sessions = dsh_home / "sessions" marker = root / "live-api-marker.txt" session_id = "installed-wheel-live-api" create_prompt = ( @@ -847,7 +824,11 @@ def smoke_sdk_live() -> None: provider="deepseek-official", model="deepseek-v4-flash", cwd=str(root), - session_root=str(sessions), + dsh_home=str(dsh_home), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key=api_key, base_url=base_url, request_timeout_seconds=180, @@ -910,18 +891,27 @@ def smoke_sdk_default(base_url: str) -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" + dsh_home = root / "home" + sessions = dsh_home / "sessions" with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), + dsh_home=str(dsh_home), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, ) as harness: result = harness.run("reply with the smoke text", session_id="default-smoke") - assert result.final_response == EXPECTED_TEXT, result.final_response + assert result.final_response == EXPECTED_TEXT, ( + f"final={result.final_response!r} finish={result.finish_reason!r} " + f"events={[event.get('type') for event in result.events]!r} " + f"turn_end={safe_turn_end(next((event.get('data', event) for event in reversed(result.events) if event.get('type') == 'turn/end'), {}))!r}" + ) assert_zstd_session_log(sessions) @@ -930,16 +920,44 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(CUSTOM_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "custom.patch.yml", sessions, [ + {"id": "tools", "config": {"mode": "both"}}, + { + "id": "system-prompt", + "config": { + "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", + }, + }, + {"id": "session-log-deepseek", "config": {"enabled": True}}, + *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), + {"id": "tool-bash", "disabled": True}, + { + "id": "tool-subagent", + "config": { + "provider": "spawn", + "toolName": "subagent", + "backgroundMode": "one-shot", + }, + }, + {"insert": [ + {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, + {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, + {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, + ]}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -963,14 +981,70 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - root = Path(temporary).resolve() editor_path = root / "created.txt" prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}" - sessions = root / "sessions" + dsh_home = root / "home" + sessions = dsh_home / "sessions" + disabled = [ + "agent-instructions", + "skill-filesystem", + "tool-skill", + "tool-bash", + "tool-jobs", + "tool-fs", + "tool-fs-search", + "tool-subagent-control", + "tool-subagent-list-agents", + "tool-subagent", + "tool-subagent-fork", + "tool-subagent-report", + "tool-workflow", + "tool-todo", + "tool-goal", + "tool-ralph", + "tool-web", + "plan-mode", + "compaction-basic", + "command-compact", + "tool-result-pruner", + ] + patch = write_profile_patch(root, "minimal.patch.yml", sessions, [ + { + "id": "system-prompt", + "config": { + "includeHarnessIdentity": False, + "includeRuntimeContext": False, + "persona": "You are a helpful software engineer assistant.", + }, + }, + *({"id": row_id, "disabled": True} for row_id in disabled), + {"id": "tool-str-replace-editor", "config": {"maxOutputChars": 16000}}, + {"insert": [ + {"id": "pty", "name": "@deepseek-ai/dsh-terminal"}, + { + "id": "terminal-bash", + "name": "@deepseek-ai/dsh-terminal-bash", + "config": {"timeoutMs": 300000}, + }, + { + "id": "persistent-bash", + "name": "@deepseek-ai/dsh-tool-bash-persistent", + "config": { + "timeoutMs": 300000, + "description": MINIMAL_BASH_DESCRIPTION, + }, + }, + ]}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(MINIMAL_CORDIS), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -997,16 +1071,23 @@ def smoke_sdk_fs_search(base_url: str, executable: Path) -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-fs-search-") as temporary: root = Path(temporary).resolve() (root / "needle.txt").write_text(f"{FS_SEARCH_MARKER}\n") - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(FS_SEARCH_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "fs-search.patch.yml", sessions, [ + {"id": "skill-filesystem", "disabled": True}, + {"id": "tool-fs-search", "config": {"sampleOverCapGlobResults": False}}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -1023,19 +1104,23 @@ def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-mcp-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" + dsh_home = root / "home" + sessions = dsh_home / "sessions" server_script = root / "mcp_server.py" server_script.write_text(MCP_SERVER_SCRIPT) - cordis = root / "cordis.yml" - cordis.write_text(mcp_cordis(server_script)) + patch = write_mcp_patch(root, sessions, server_script) discovery_log = server_script.with_suffix(".log") with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=None if executable is None else str(executable), + dsh_bin=None if executable is None else str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -1052,22 +1137,131 @@ def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: assert_session_log(sessions, root, MCP_TEXT, "mcp__fixture__add", "42") +def smoke_sdk_profile_plugin(base_url: str) -> None: + """Install an external bundle through Python's dsh command and load it in the SDK.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-profile-plugin-") as temporary: + root = Path(temporary).resolve() + dsh_home = root / "home" + plugin = root / "plugin" + plugin.mkdir() + (plugin / "package.json").write_text(json.dumps({ + "name": "dsh-python-blackbox-plugin", + "version": "1.0.0", + "private": True, + "type": "module", + "exports": "./index.js", + "peerDependencies": {"@deepseek-ai/cordis": "*"}, + "dsh": {"bundle": {"patch": "./cordis.patch.yml"}}, + }, indent=2)) + (plugin / "index.js").write_text( + "import { Context } from '@deepseek-ai/cordis'\n" + "export const name = 'python-sdk-blackbox-plugin'\n" + "export const inject = ['systemPrompt']\n" + "export function apply(ctx) {\n" + " if (!(ctx instanceof Context)) throw new Error('external plugin loaded a second Cordis instance')\n" + " ctx.effect(() => ctx.systemPrompt.section({\n" + " name: 'python-sdk:blackbox-plugin',\n" + " order: 10,\n" + f" text: '{PROFILE_PLUGIN_MARKER}',\n" + " }))\n" + "}\n" + ) + (plugin / "cordis.patch.yml").write_text(json.dumps([{ + "insert": [{"id": "python-sdk-blackbox-plugin", "name": "dsh-python-blackbox-plugin"}], + }], indent=2)) + + dsh = Path(sysconfig.get_path("scripts")) / "dsh" + environment = {**os.environ, "DSH_HOME": str(dsh_home)} + installed = subprocess.run( + [str(dsh), "plugin", "--profile", "sdk", "add", f"file:{plugin}"], + cwd=root, + env=environment, + text=True, + capture_output=True, + check=False, + ) + if installed.returncode != 0: + raise AssertionError( + f"Python-installed dsh could not add the external profile plugin: " + f"stdout={installed.stdout!r} stderr={installed.stderr!r}" + ) + manifest = json.loads((dsh_home / "profiles" / "sdk" / "package.json").read_text()) + if "dsh-python-blackbox-plugin" not in manifest.get("dependencies", {}): + raise AssertionError(f"dsh plugin did not record the external dependency: {manifest}") + if "dsh-python-blackbox-plugin" not in manifest["dsh"]["profile"]["bundles"]: + raise AssertionError(f"dsh plugin did not activate the external bundle: {manifest}") + + harness = DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + dsh_home=str(dsh_home), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) + try: + with harness: + result = harness.run(PROFILE_PLUGIN_PROMPT, session_id="profile-plugin-smoke") + except Exception as error: + raise AssertionError( + f"external profile plugin runtime failed: {harness.client._runtime_diagnostics()}" + ) from error + + assert result.final_response == PROFILE_PLUGIN_TEXT, result.final_response + assert_zstd_session_log(dsh_home / "sessions") + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness with tempfile.TemporaryDirectory(prefix="dsh-sdk-snapshot-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(CUSTOM_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "snapshot.patch.yml", sessions, [ + {"id": "tools", "config": {"mode": "both"}}, + { + "id": "system-prompt", + "config": { + "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", + }, + }, + {"id": "session-log-deepseek", "config": {"enabled": True}}, + *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), + {"id": "tool-bash", "disabled": True}, + { + "id": "tool-subagent", + "config": { + "provider": "spawn", + "toolName": "subagent", + "backgroundMode": "one-shot", + }, + }, + {"insert": [ + {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, + {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, + {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, + ]}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -1103,9 +1297,33 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots with tempfile.TemporaryDirectory(prefix="dsh-sdk-restart-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(CUSTOM_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "restart.patch.yml", sessions, [ + {"id": "tools", "config": {"mode": "both"}}, + { + "id": "system-prompt", + "config": { + "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", + }, + }, + {"id": "session-log-deepseek", "config": {"enabled": True}}, + *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), + {"id": "tool-bash", "disabled": True}, + { + "id": "tool-subagent", + "config": { + "provider": "spawn", + "toolName": "subagent", + "backgroundMode": "one-shot", + }, + }, + {"insert": [ + {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, + {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, + {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, + ]}, + ]) first_request = len(MockModelHandler.requests) def run(prompt: str, session_id: str) -> "RunResult": @@ -1113,9 +1331,13 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -1155,18 +1377,22 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots def smoke_direct(base_url: str, executable: Path) -> None: with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(CUSTOM_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "direct.patch.yml", sessions, []) environment = { **os.environ, - "DSH_CORDIS_CONFIG": str(cordis), - "DSH_SESSION_ROOT": str(sessions), - "DSH_CWD": str(root), + "DSH_HOME": str(dsh_home), + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", "DEEPSEEK_API_KEY": "sk-keyless-smoke", "DEEPSEEK_BASE_URL": base_url, } - peer = RuntimePeer([str(executable)], root, environment) + peer = RuntimePeer( + [str(executable), "--profile", "sdk", "--patch", str(patch)], + root, + environment, + ) try: peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek-official", "model": "smoke-model"}}) peer.read_until(lambda message: message.get("id") == "initialize") @@ -1419,7 +1645,6 @@ def build_snapshot_files( {"method": notification.method, "payload": notification.payload} for notification in result.notifications ], - "session_root": result.session_root, } normalized_result = normalize_snapshot_value(result_value, replacements) files = { @@ -1461,7 +1686,6 @@ def build_restart_snapshot_files( "finish_reason": result.finish_reason, "eventTypes": [event.get("type") for event in result.events], "notificationMethods": [notification.method for notification in result.notifications], - "session_root": result.session_root, } for result in (first, second) ] diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 0889fd6b9f..20aecae839 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -4,7 +4,7 @@ "events": [ { "type": "agent/inbox/spliced", - "seq": 0, + "seq": 3, "time": 0, "data": { "target": "next-turn", @@ -28,7 +28,7 @@ }, { "type": "turn/start", - "seq": 1, + "seq": 4, "time": 0, "data": { "turn": 1 @@ -36,7 +36,7 @@ }, { "type": "agent/inbox/spliced", - "seq": 2, + "seq": 5, "time": 0, "data": { "target": "next-turn", @@ -47,7 +47,7 @@ }, { "type": "step/start", - "seq": 3, + "seq": 6, "time": 0, "data": { "turn": 1, @@ -56,7 +56,7 @@ }, { "type": "user/message", - "seq": 4, + "seq": 7, "time": 0, "data": { "content": [ @@ -73,14 +73,45 @@ }, "surfaceOp": "append" }, + { + "type": "user/message", + "seq": 8, + "time": 0, + "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": "{{messageId}}" + }, + "surfaceOp": "append" + }, { "type": "session/title", - "seq": 5, + "seq": 9, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", "messageSeqs": [ - 4 + 7 ], "source": { "kind": "fallback" @@ -89,7 +120,7 @@ }, { "type": "request/header", - "seq": 6, + "seq": 10, "time": 0, "data": { "header": { @@ -125,7 +156,7 @@ }, { "type": "request/context", - "seq": 7, + "seq": 11, "time": 0, "data": { "provider": "deepseek-official", @@ -135,16 +166,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 8, + "seq": 12, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 7 + "throughSeq": 11 } }, { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -158,7 +189,7 @@ }, { "type": "assistant/chunk", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -174,7 +205,7 @@ }, { "type": "assistant/chunk", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -193,7 +224,7 @@ }, { "type": "assistant/chunk", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -209,7 +240,7 @@ }, { "type": "assistant/chunk", - "seq": 13, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -224,7 +255,7 @@ }, { "type": "assistant/message", - "seq": 14, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -252,17 +283,17 @@ } }, "sourceEventSeqs": [ - 9, - 10, - 11, - 12, - 13 + 13, + 14, + 15, + 16, + 17 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 15, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -274,7 +305,7 @@ }, { "type": "tool/result", - "seq": 16, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -306,13 +337,13 @@ } }, "sourceEventSeqs": [ - 15 + 19 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 17, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -321,7 +352,7 @@ }, { "type": "step/start", - "seq": 18, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -330,16 +361,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 19, + "seq": 23, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 18 + "throughSeq": 22 } }, { "type": "assistant/chunk", - "seq": 20, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -353,7 +384,7 @@ }, { "type": "assistant/chunk", - "seq": 21, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -369,7 +400,7 @@ }, { "type": "assistant/chunk", - "seq": 22, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -388,7 +419,7 @@ }, { "type": "assistant/chunk", - "seq": 23, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -404,7 +435,7 @@ }, { "type": "assistant/chunk", - "seq": 24, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -419,7 +450,7 @@ }, { "type": "assistant/message", - "seq": 25, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -447,17 +478,17 @@ } }, "sourceEventSeqs": [ - 20, - 21, - 22, - 23, - 24 + 24, + 25, + 26, + 27, + 28 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 26, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -469,7 +500,7 @@ }, { "type": "tool/result", - "seq": 27, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -502,13 +533,13 @@ } }, "sourceEventSeqs": [ - 26 + 30 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 28, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -517,7 +548,7 @@ }, { "type": "step/start", - "seq": 29, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -526,7 +557,7 @@ }, { "type": "request/header", - "seq": 30, + "seq": 34, "time": 0, "data": { "header": { @@ -563,16 +594,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 31, + "seq": 35, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 30 + "throughSeq": 34 } }, { "type": "assistant/chunk", - "seq": 32, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -586,7 +617,7 @@ }, { "type": "assistant/chunk", - "seq": 33, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -602,7 +633,7 @@ }, { "type": "assistant/chunk", - "seq": 34, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -621,7 +652,7 @@ }, { "type": "assistant/chunk", - "seq": 35, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -637,7 +668,7 @@ }, { "type": "assistant/chunk", - "seq": 36, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -652,7 +683,7 @@ }, { "type": "assistant/message", - "seq": 37, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -680,17 +711,17 @@ } }, "sourceEventSeqs": [ - 32, - 33, - 34, - 35, - 36 + 36, + 37, + 38, + 39, + 40 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 38, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -702,7 +733,7 @@ }, { "type": "tool/code-dispatch-start", - "seq": 39, + "seq": 43, "time": 0, "data": { "rootCallId": "advanced-code", @@ -716,7 +747,7 @@ }, { "type": "tool/code-dispatch", - "seq": 40, + "seq": 44, "time": 0, "data": { "rootCallId": "advanced-code", @@ -737,7 +768,7 @@ }, { "type": "tool/result", - "seq": 41, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -765,13 +796,13 @@ } }, "sourceEventSeqs": [ - 38 + 42 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 42, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -780,7 +811,7 @@ }, { "type": "step/start", - "seq": 43, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -789,16 +820,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 44, + "seq": 48, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 43 + "throughSeq": 47 } }, { "type": "assistant/chunk", - "seq": 45, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -812,7 +843,7 @@ }, { "type": "assistant/chunk", - "seq": 46, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -828,7 +859,7 @@ }, { "type": "assistant/chunk", - "seq": 47, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -847,7 +878,7 @@ }, { "type": "assistant/chunk", - "seq": 48, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -863,7 +894,7 @@ }, { "type": "assistant/chunk", - "seq": 49, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -878,7 +909,7 @@ }, { "type": "assistant/message", - "seq": 50, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -906,17 +937,17 @@ } }, "sourceEventSeqs": [ - 45, - 46, - 47, - 48, - 49 + 49, + 50, + 51, + 52, + 53 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 51, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -928,7 +959,7 @@ }, { "type": "tool/result", - "seq": 52, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -956,13 +987,13 @@ } }, "sourceEventSeqs": [ - 51 + 55 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 53, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -971,7 +1002,7 @@ }, { "type": "step/start", - "seq": 54, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -980,16 +1011,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 55, + "seq": 59, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 54 + "throughSeq": 58 } }, { "type": "assistant/chunk", - "seq": 56, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -1003,7 +1034,7 @@ }, { "type": "assistant/chunk", - "seq": 57, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -1019,7 +1050,7 @@ }, { "type": "assistant/chunk", - "seq": 58, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1038,7 +1069,7 @@ }, { "type": "assistant/chunk", - "seq": 59, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1054,7 +1085,7 @@ }, { "type": "assistant/chunk", - "seq": 60, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -1069,7 +1100,7 @@ }, { "type": "assistant/message", - "seq": 61, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -1097,17 +1128,17 @@ } }, "sourceEventSeqs": [ - 56, - 57, - 58, - 59, - 60 + 60, + 61, + 62, + 63, + 64 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 62, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1119,7 +1150,7 @@ }, { "type": "tool-workflow/run-start", - "seq": 63, + "seq": 67, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1128,7 +1159,7 @@ }, { "type": "tool-workflow/agent-start", - "seq": 64, + "seq": 68, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1140,7 +1171,7 @@ }, { "type": "tool-workflow/agent-end", - "seq": 65, + "seq": 69, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1150,7 +1181,7 @@ }, { "type": "tool-workflow/run-end", - "seq": 66, + "seq": 70, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1159,7 +1190,7 @@ }, { "type": "tool/result", - "seq": 67, + "seq": 71, "time": 0, "data": { "turn": 1, @@ -1187,13 +1218,13 @@ } }, "sourceEventSeqs": [ - 62 + 66 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 68, + "seq": 72, "time": 0, "data": { "turn": 1, @@ -1202,7 +1233,7 @@ }, { "type": "step/start", - "seq": 69, + "seq": 73, "time": 0, "data": { "turn": 1, @@ -1211,16 +1242,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 70, + "seq": 74, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 69 + "throughSeq": 73 } }, { "type": "assistant/chunk", - "seq": 71, + "seq": 75, "time": 0, "data": { "turn": 1, @@ -1234,7 +1265,7 @@ }, { "type": "assistant/chunk", - "seq": 72, + "seq": 76, "time": 0, "data": { "turn": 1, @@ -1250,7 +1281,7 @@ }, { "type": "assistant/chunk", - "seq": 73, + "seq": 77, "time": 0, "data": { "turn": 1, @@ -1269,7 +1300,7 @@ }, { "type": "assistant/chunk", - "seq": 74, + "seq": 78, "time": 0, "data": { "turn": 1, @@ -1285,7 +1316,7 @@ }, { "type": "assistant/chunk", - "seq": 75, + "seq": 79, "time": 0, "data": { "turn": 1, @@ -1300,7 +1331,7 @@ }, { "type": "assistant/message", - "seq": 76, + "seq": 80, "time": 0, "data": { "turn": 1, @@ -1328,17 +1359,17 @@ } }, "sourceEventSeqs": [ - 71, - 72, - 73, - 74, - 75 + 75, + 76, + 77, + 78, + 79 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 77, + "seq": 81, "time": 0, "data": { "turn": 1, @@ -1350,7 +1381,7 @@ }, { "type": "tool/result", - "seq": 78, + "seq": 82, "time": 0, "data": { "turn": 1, @@ -1378,13 +1409,13 @@ } }, "sourceEventSeqs": [ - 77 + 81 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 79, + "seq": 83, "time": 0, "data": { "turn": 1, @@ -1393,7 +1424,7 @@ }, { "type": "step/start", - "seq": 80, + "seq": 84, "time": 0, "data": { "turn": 1, @@ -1402,7 +1433,7 @@ }, { "type": "request/header", - "seq": 81, + "seq": 85, "time": 0, "data": { "header": { @@ -1438,16 +1469,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 82, + "seq": 86, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 81 + "throughSeq": 85 } }, { "type": "assistant/chunk", - "seq": 83, + "seq": 87, "time": 0, "data": { "turn": 1, @@ -1461,7 +1492,7 @@ }, { "type": "assistant/chunk", - "seq": 84, + "seq": 88, "time": 0, "data": { "turn": 1, @@ -1475,7 +1506,7 @@ }, { "type": "assistant/chunk", - "seq": 85, + "seq": 89, "time": 0, "data": { "turn": 1, @@ -1492,7 +1523,7 @@ }, { "type": "assistant/chunk", - "seq": 86, + "seq": 90, "time": 0, "data": { "turn": 1, @@ -1508,7 +1539,7 @@ }, { "type": "assistant/chunk", - "seq": 87, + "seq": 91, "time": 0, "data": { "turn": 1, @@ -1523,7 +1554,7 @@ }, { "type": "assistant/message", - "seq": 88, + "seq": 92, "time": 0, "data": { "turn": 1, @@ -1549,17 +1580,17 @@ } }, "sourceEventSeqs": [ - 83, - 84, - 85, - 86, - 87 + 87, + 88, + 89, + 90, + 91 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 89, + "seq": 93, "time": 0, "data": { "turn": 1, @@ -1568,7 +1599,7 @@ }, { "type": "turn/end", - "seq": 90, + "seq": 94, "time": 0, "data": { "turn": 1, @@ -1585,7 +1616,7 @@ "sessionId": "{{parent}}", "event": { "type": "agent/inbox/spliced", - "seq": 0, + "seq": 3, "time": 0, "data": { "target": "next-turn", @@ -1622,7 +1653,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/start", - "seq": 1, + "seq": 4, "time": 0, "data": { "turn": 1 @@ -1636,7 +1667,7 @@ "sessionId": "{{parent}}", "event": { "type": "agent/inbox/spliced", - "seq": 2, + "seq": 5, "time": 0, "data": { "target": "next-turn", @@ -1653,7 +1684,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 3, + "seq": 6, "time": 0, "data": { "turn": 1, @@ -1668,7 +1699,7 @@ "sessionId": "{{parent}}", "event": { "type": "user/message", - "seq": 4, + "seq": 7, "time": 0, "data": { "content": [ @@ -1687,18 +1718,55 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "user/message", + "seq": 8, + "time": 0, + "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": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{parent}}", "event": { "type": "session/title", - "seq": 5, + "seq": 9, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", "messageSeqs": [ - 4 + 7 ], "source": { "kind": "fallback" @@ -1713,7 +1781,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 6, + "seq": 10, "time": 0, "data": { "header": { @@ -1755,7 +1823,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/context", - "seq": 7, + "seq": 11, "time": 0, "data": { "provider": "deepseek-official", @@ -1771,11 +1839,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 8, + "seq": 12, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 7 + "throughSeq": 11 } } } @@ -1786,7 +1854,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -1806,7 +1874,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -1828,7 +1896,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -1853,7 +1921,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -1875,7 +1943,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -1896,7 +1964,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 14, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -1924,11 +1992,11 @@ } }, "sourceEventSeqs": [ - 9, - 10, - 11, - 12, - 13 + 13, + 14, + 15, + 16, + 17 ], "surfaceOp": "append" } @@ -1940,7 +2008,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 15, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -1958,7 +2026,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 16, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -1990,7 +2058,7 @@ } }, "sourceEventSeqs": [ - 15 + 19 ], "surfaceOp": "append" } @@ -2002,7 +2070,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 17, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -2017,7 +2085,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 18, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -2032,11 +2100,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 19, + "seq": 23, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 18 + "throughSeq": 22 } } } @@ -2047,7 +2115,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 20, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -2067,7 +2135,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 21, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -2089,7 +2157,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 22, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -2114,7 +2182,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 23, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -2136,7 +2204,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 24, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -2157,7 +2225,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 25, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -2185,11 +2253,11 @@ } }, "sourceEventSeqs": [ - 20, - 21, - 22, - 23, - 24 + 24, + 25, + 26, + 27, + 28 ], "surfaceOp": "append" } @@ -2201,7 +2269,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 26, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -2219,7 +2287,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 27, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -2252,7 +2320,7 @@ } }, "sourceEventSeqs": [ - 26 + 30 ], "surfaceOp": "append" } @@ -2264,7 +2332,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 28, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -2279,7 +2347,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 29, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -2294,7 +2362,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 30, + "seq": 34, "time": 0, "data": { "header": { @@ -2337,11 +2405,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 31, + "seq": 35, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 30 + "throughSeq": 34 } } } @@ -2352,7 +2420,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 32, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -2372,7 +2440,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 33, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -2394,7 +2462,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 34, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -2419,7 +2487,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 35, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -2441,7 +2509,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 36, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -2462,7 +2530,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 37, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -2490,11 +2558,11 @@ } }, "sourceEventSeqs": [ - 32, - 33, - 34, - 35, - 36 + 36, + 37, + 38, + 39, + 40 ], "surfaceOp": "append" } @@ -2506,7 +2574,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 38, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -2524,7 +2592,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch-start", - "seq": 39, + "seq": 43, "time": 0, "data": { "rootCallId": "advanced-code", @@ -2544,7 +2612,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch", - "seq": 40, + "seq": 44, "time": 0, "data": { "rootCallId": "advanced-code", @@ -2571,7 +2639,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 41, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -2599,7 +2667,7 @@ } }, "sourceEventSeqs": [ - 38 + 42 ], "surfaceOp": "append" } @@ -2611,7 +2679,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 42, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -2626,7 +2694,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 43, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -2641,11 +2709,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 44, + "seq": 48, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 43 + "throughSeq": 47 } } } @@ -2656,7 +2724,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 45, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -2676,7 +2744,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 46, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -2698,7 +2766,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 47, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -2723,7 +2791,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 48, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -2745,7 +2813,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 49, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -2766,7 +2834,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 50, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -2794,11 +2862,11 @@ } }, "sourceEventSeqs": [ - 45, - 46, - 47, - 48, - 49 + 49, + 50, + 51, + 52, + 53 ], "surfaceOp": "append" } @@ -2810,7 +2878,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 51, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -2829,13 +2897,27 @@ "childSessionId": "{{child-1}}" } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "permission/preset", + "seq": 2, + "time": 0, + "data": { + "preset": "danger-full-access" + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "agent/inbox/spliced", - "seq": 0, + "seq": 3, "time": 0, "data": { "target": "next-turn", @@ -2872,7 +2954,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/start", - "seq": 1, + "seq": 4, "time": 0, "data": { "turn": 1 @@ -2886,7 +2968,7 @@ "sessionId": "{{child-1}}", "event": { "type": "agent/inbox/spliced", - "seq": 2, + "seq": 5, "time": 0, "data": { "target": "next-turn", @@ -2903,7 +2985,7 @@ "sessionId": "{{child-1}}", "event": { "type": "subagent/descriptor", - "seq": 3, + "seq": 6, "time": 0, "data": { "version": 2, @@ -2920,7 +3002,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/start", - "seq": 4, + "seq": 7, "time": 0, "data": { "turn": 1, @@ -2935,7 +3017,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 5, + "seq": 8, "time": 0, "data": { "content": [ @@ -2960,13 +3042,13 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 6, + "seq": 9, "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." + "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": { @@ -2974,6 +3056,14 @@ "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." @@ -2993,12 +3083,12 @@ "sessionId": "{{child-1}}", "event": { "type": "session/title", - "seq": 7, + "seq": 10, "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ - 5 + 8 ], "source": { "kind": "fallback" @@ -3013,7 +3103,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 8, + "seq": 11, "time": 0, "data": { "header": { @@ -3056,7 +3146,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/context", - "seq": 9, + "seq": 12, "time": 0, "data": { "provider": "deepseek-official", @@ -3072,11 +3162,11 @@ "sessionId": "{{child-1}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 10, + "seq": 13, "time": 0, "data": { "sessionId": "{{child-1}}", - "throughSeq": 9 + "throughSeq": 12 } } } @@ -3087,7 +3177,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -3107,7 +3197,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -3127,7 +3217,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -3150,7 +3240,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -3172,7 +3262,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 15, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -3193,7 +3283,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -3219,11 +3309,11 @@ } }, "sourceEventSeqs": [ - 11, - 12, - 13, 14, - 15 + 15, + 16, + 17, + 18 ], "surfaceOp": "append" } @@ -3235,7 +3325,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -3250,7 +3340,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -3291,7 +3381,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 52, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -3319,7 +3409,7 @@ } }, "sourceEventSeqs": [ - 51 + 55 ], "surfaceOp": "append" } @@ -3331,7 +3421,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 53, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -3346,7 +3436,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 54, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -3361,11 +3451,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 55, + "seq": 59, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 54 + "throughSeq": 58 } } } @@ -3376,7 +3466,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 56, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -3396,7 +3486,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 57, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -3418,7 +3508,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 58, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -3443,7 +3533,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 59, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -3465,7 +3555,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 60, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -3486,7 +3576,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 61, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -3514,11 +3604,11 @@ } }, "sourceEventSeqs": [ - 56, - 57, - 58, - 59, - 60 + 60, + 61, + 62, + 63, + 64 ], "surfaceOp": "append" } @@ -3530,7 +3620,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 62, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -3548,7 +3638,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/run-start", - "seq": 63, + "seq": 67, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -3564,13 +3654,27 @@ "childSessionId": "{{child-2}}" } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "permission/preset", + "seq": 2, + "time": 0, + "data": { + "preset": "danger-full-access" + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "agent/inbox/spliced", - "seq": 0, + "seq": 3, "time": 0, "data": { "target": "next-turn", @@ -3607,7 +3711,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/start", - "seq": 1, + "seq": 4, "time": 0, "data": { "turn": 1 @@ -3621,7 +3725,7 @@ "sessionId": "{{child-2}}", "event": { "type": "agent/inbox/spliced", - "seq": 2, + "seq": 5, "time": 0, "data": { "target": "next-turn", @@ -3632,13 +3736,31 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool-workflow/agent-start", + "seq": 68, + "time": 0, + "data": { + "runId": "{{workflow-run}}", + "seq": 1, + "label": "workflow-child", + "phase": "Delegate", + "childId": "{{child-2}}" + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "subagent/descriptor", - "seq": 3, + "seq": 6, "time": 0, "data": { "version": 2, @@ -3654,7 +3776,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/start", - "seq": 4, + "seq": 7, "time": 0, "data": { "turn": 1, @@ -3669,7 +3791,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 5, + "seq": 8, "time": 0, "data": { "content": [ @@ -3694,13 +3816,13 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 6, + "seq": 9, "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." + "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": { @@ -3708,6 +3830,14 @@ "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." @@ -3727,12 +3857,12 @@ "sessionId": "{{child-2}}", "event": { "type": "session/title", - "seq": 7, + "seq": 10, "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ - 5 + 8 ], "source": { "kind": "fallback" @@ -3747,7 +3877,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 8, + "seq": 11, "time": 0, "data": { "header": { @@ -3790,7 +3920,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/context", - "seq": 9, + "seq": 12, "time": 0, "data": { "provider": "deepseek-official", @@ -3800,35 +3930,17 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "tool-workflow/agent-start", - "seq": 64, - "time": 0, - "data": { - "runId": "{{workflow-run}}", - "seq": 1, - "label": "workflow-child", - "phase": "Delegate", - "childId": "{{child-2}}" - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 10, + "seq": 13, "time": 0, "data": { "sessionId": "{{child-2}}", - "throughSeq": 9 + "throughSeq": 12 } } } @@ -3839,7 +3951,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -3859,7 +3971,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -3879,7 +3991,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -3902,7 +4014,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -3924,7 +4036,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 15, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -3945,7 +4057,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -3971,11 +4083,11 @@ } }, "sourceEventSeqs": [ - 11, - 12, - 13, 14, - 15 + 15, + 16, + 17, + 18 ], "surfaceOp": "append" } @@ -3987,7 +4099,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -4002,7 +4114,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -4043,7 +4155,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/agent-end", - "seq": 65, + "seq": 69, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -4059,7 +4171,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/run-end", - "seq": 66, + "seq": 70, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -4074,7 +4186,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 67, + "seq": 71, "time": 0, "data": { "turn": 1, @@ -4102,7 +4214,7 @@ } }, "sourceEventSeqs": [ - 62 + 66 ], "surfaceOp": "append" } @@ -4114,7 +4226,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 68, + "seq": 72, "time": 0, "data": { "turn": 1, @@ -4129,7 +4241,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 69, + "seq": 73, "time": 0, "data": { "turn": 1, @@ -4144,11 +4256,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 70, + "seq": 74, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 69 + "throughSeq": 73 } } } @@ -4159,7 +4271,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 71, + "seq": 75, "time": 0, "data": { "turn": 1, @@ -4179,7 +4291,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 72, + "seq": 76, "time": 0, "data": { "turn": 1, @@ -4201,7 +4313,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 73, + "seq": 77, "time": 0, "data": { "turn": 1, @@ -4226,7 +4338,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 74, + "seq": 78, "time": 0, "data": { "turn": 1, @@ -4248,7 +4360,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 75, + "seq": 79, "time": 0, "data": { "turn": 1, @@ -4269,7 +4381,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 76, + "seq": 80, "time": 0, "data": { "turn": 1, @@ -4297,11 +4409,11 @@ } }, "sourceEventSeqs": [ - 71, - 72, - 73, - 74, - 75 + 75, + 76, + 77, + 78, + 79 ], "surfaceOp": "append" } @@ -4313,7 +4425,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 77, + "seq": 81, "time": 0, "data": { "turn": 1, @@ -4331,7 +4443,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 78, + "seq": 82, "time": 0, "data": { "turn": 1, @@ -4359,7 +4471,7 @@ } }, "sourceEventSeqs": [ - 77 + 81 ], "surfaceOp": "append" } @@ -4371,7 +4483,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 79, + "seq": 83, "time": 0, "data": { "turn": 1, @@ -4386,7 +4498,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 80, + "seq": 84, "time": 0, "data": { "turn": 1, @@ -4401,7 +4513,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 81, + "seq": 85, "time": 0, "data": { "header": { @@ -4443,11 +4555,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 82, + "seq": 86, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 81 + "throughSeq": 85 } } } @@ -4458,7 +4570,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 83, + "seq": 87, "time": 0, "data": { "turn": 1, @@ -4478,7 +4590,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 84, + "seq": 88, "time": 0, "data": { "turn": 1, @@ -4498,7 +4610,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 85, + "seq": 89, "time": 0, "data": { "turn": 1, @@ -4521,7 +4633,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 86, + "seq": 90, "time": 0, "data": { "turn": 1, @@ -4543,7 +4655,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 87, + "seq": 91, "time": 0, "data": { "turn": 1, @@ -4564,7 +4676,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 88, + "seq": 92, "time": 0, "data": { "turn": 1, @@ -4590,11 +4702,11 @@ } }, "sourceEventSeqs": [ - 83, - 84, - 85, - 86, - 87 + 87, + 88, + 89, + 90, + 91 ], "surfaceOp": "append" } @@ -4606,7 +4718,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 89, + "seq": 93, "time": 0, "data": { "turn": 1, @@ -4621,7 +4733,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 90, + "seq": 94, "time": 0, "data": { "turn": 1, @@ -4639,6 +4751,5 @@ "status": "idle" } } - ], - "session_root": "{{cwd}}/sessions" + ] } diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 6e2f62bfe1..db7a626752 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,20 +1,23 @@ {"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"user/message","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":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","throughSeq":9}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","throughSeq":12}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index e11cec5548..eed0d049f5 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,20 +1,23 @@ {"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"user/message","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":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","throughSeq":9}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","throughSeq":12}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 2e65037b37..a361560a35 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,92 +1,96 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"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":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":7}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":11}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":18}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":22}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-run","name":"cordis_run","argumentsDelta":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[26],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[30],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"change"}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":30}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":34}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[42],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":43}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":47}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"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","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":4,"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":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"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":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","data":{"turn":1,"step":4,"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":"{{messageId}}"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":4,"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":"{{messageId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":54}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":58}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} {"type":"tool-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}} {"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}} {"type":"tool-workflow/agent-end","data":{"runId":"{{workflow-run}}","seq":1,"outcome":"completed"}} {"type":"tool-workflow/run-end","data":{"runId":"{{workflow-run}}","stopReason":"completed"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[66],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":69}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":73}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-undefine","name":"cordis_undefine","argumentsDelta":"{\"pluginId\": \"snap-1\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[71,72,73,74,75],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[77],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[81],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"change"}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":81}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":85}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[83,84,85,86,87],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[87,88,89,90,91],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/restart/requests.json b/scripts/snapshots/python-sdk-single-exe/restart/requests.json index dd5f925294..a7efe0f6ce 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/requests.json +++ b/scripts/snapshots/python-sdk-single-exe/restart/requests.json @@ -9,6 +9,10 @@ { "role": "user", "content": "Complete the first isolated Python SDK process turn." + }, + { + "role": "user", + "content": "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`)." } ], "toolNames": [ @@ -37,6 +41,10 @@ { "role": "user", "content": "Complete the second isolated Python SDK process turn." + }, + { + "role": "user", + "content": "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`)." } ], "toolNames": [ diff --git a/scripts/snapshots/python-sdk-single-exe/restart/result.json b/scripts/snapshots/python-sdk-single-exe/restart/result.json index 32911ef466..d1aac3e127 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/result.json +++ b/scripts/snapshots/python-sdk-single-exe/restart/result.json @@ -9,51 +9,6 @@ "agent/inbox/spliced", "step/start", "user/message", - "session/title", - "request/header", - "request/context", - "session-log-deepseek/delivery-accepted", - "assistant/chunk", - "assistant/chunk", - "assistant/chunk", - "assistant/chunk", - "assistant/chunk", - "assistant/message", - "step/end", - "turn/end" - ], - "notificationMethods": [ - "session.event", - "session.status", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.status" - ], - "session_root": "{{sessions}}" - }, - { - "session_id": "{{session-2}}", - "final_response": "PROCESS_TWO_OK", - "finish_reason": "completed", - "eventTypes": [ - "agent/inbox/spliced", - "turn/start", - "agent/inbox/spliced", - "step/start", "user/message", "session/title", "request/header", @@ -87,8 +42,55 @@ "session.event", "session.event", "session.event", + "session.event", "session.status" + ] + }, + { + "session_id": "{{session-2}}", + "final_response": "PROCESS_TWO_OK", + "finish_reason": "completed", + "eventTypes": [ + "agent/inbox/spliced", + "turn/start", + "agent/inbox/spliced", + "step/start", + "user/message", + "user/message", + "session/title", + "request/header", + "request/context", + "session-log-deepseek/delivery-accepted", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/message", + "step/end", + "turn/end" ], - "session_root": "{{sessions}}" + "notificationMethods": [ + "session.event", + "session.status", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.status" + ] } ] diff --git a/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl index 38e5f98ff6..babaec2193 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl @@ -1,18 +1,22 @@ {"type":"session","version":0,"id":"{{session-1}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Complete the first isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Complete the first isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Complete the first isolated Python","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"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":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Complete the first isolated Python","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-1}}","throughSeq":7}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-1}}","throughSeq":11}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"PROCESS_ONE_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_ONE_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl index 523f784dfa..afd9753b4a 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl @@ -1,18 +1,22 @@ {"type":"session","version":0,"id":"{{session-2}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Complete the second isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Complete the second isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Complete the second isolated Python","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"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":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Complete the second isolated Python","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-2}}","throughSeq":7}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-2}}","throughSeq":11}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"PROCESS_TWO_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_TWO_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From f0f9b294ddee7a0ced10bb19aa2c8af4b91c2a35 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:55 +0800 Subject: [PATCH 063/138] docs(python): make the dsh profile runtime current Document dsh as the only application launcher across architecture, CLI, SDK, app-boot, Python package, contributor, tutorial, and example references. Explain explicit home selection, profile and patch precedence, persistent external plugin installation, the Node-free runtime path, and the absence of complete-config or ~/.dsh fallbacks. Record the Python profile-runtime decision and update the active naming, installed-wheel, and SEA packaging notes with precise supersession. Regenerate the configuration catalog and module graph after deleting the carrier, update both reviewed languages and pairing records, and classify the retained standalone Cordis files as lower-level test fixtures rather than launch interfaces. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 8 +- ...-executable-sdk-runtime-distribution.zh.md | 8 +- ...aming-contract-and-rename-ledger.i18n.yaml | 4 +- ...itory-naming-contract-and-rename-ledger.md | 8 +- ...ry-naming-contract-and-rename-ledger.zh.md | 8 +- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 6 ++ ...26-08-23-python-sdk-dsh-profile-runtime.md | 53 ++++++++++++ ...08-23-python-sdk-dsh-profile-runtime.zh.md | 53 ++++++++++++ ...talled-python-wheel-black-box-ci.i18n.yaml | 4 +- ...-23-installed-python-wheel-black-box-ci.md | 4 +- ...-installed-python-wheel-black-box-ci.zh.md | 4 +- AGENTS.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 - docs/config-catalog.zh.md | 1 - docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 - docs/module-graph.zh.md | 3 - docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 66 ++++++++------- docs/user/guide/python-sdk.zh.md | 84 ++++++++++--------- examples/python-sdk-agent/README.i18n.yaml | 4 +- examples/python-sdk-agent/README.md | 54 ++++++------ examples/python-sdk-agent/README.zh.md | 52 ++++++------ examples/python-sdk-agent/cordis.snapshot.yml | 7 +- examples/python-sdk-agent/cordis.yml | 5 +- examples/python-sdk-agent/minimal.cordis.yml | 7 +- packages/README.i18n.yaml | 4 +- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 4 +- packages/boot/app-boot/README.zh.md | 4 +- packages/sdk/README.i18n.yaml | 4 +- packages/sdk/README.md | 3 +- packages/sdk/README.zh.md | 3 +- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 8 +- packages/sdk/server/README.zh.md | 8 +- python/README.i18n.yaml | 4 +- python/README.md | 4 +- python/README.zh.md | 4 +- python/development.i18n.yaml | 4 +- python/development.md | 14 ++-- python/development.zh.md | 14 ++-- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 41 +++++---- python/sdk-runtime/README.zh.md | 41 +++++---- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 67 +++++++++------ python/sdk/README.zh.md | 64 ++++++++------ 61 files changed, 480 insertions(+), 324 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index be7aedb4bd..29b7caeb50 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: e5438fc01fdd634cd4d8535dfa37a1de356da04d -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 218133f077bed7584b6a26fea5f939b64d4fd670 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 50e63bbc4f664f9450f01acf45888991a27517d2 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cf92f42f508f005c64f09aa0127b28ed162cede5 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index e5438fc01f..50e63bbc4f 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,14 +23,14 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. -### The serving interface is a plugin: the two packages sdk/server + sdk/python-runtime +### The serving interface is a plugin inside the dsh application -The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: +The deterministic serving surface is a plugin selected by the packaged `dsh` application: - [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-server`): the pure protocol plugin; on apply it mounts `HarnessSdkJsonRpcServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). -- [`packages/sdk/python-runtime`](../../../../packages/sdk/python-runtime/README.md) (`@deepseek-ai/dsh-sdk-python-runtime`): a private packaged entry — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the packaged entry (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). +- [`apps/cli`](../../../../apps/cli/README.md) (`@deepseek-ai/dsh`): the packaged application entry; its `sdk` profile mounts `dsh-sdk-jsonrpc-server`, and the CLI owns environment layering, profile composition, stdin/signal shutdown, and process exit. -Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. +The Python client supplies an explicit Harness home and selects the `sdk` profile plus ordered patch files. A missing home, profile, bundle, or server row fails loudly; there is no external complete-config fallback. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this application surface. ### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 218133f077..cf92f42f50 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,14 +23,14 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 -### 对外服务接口也是插件:sdk/server + sdk/python-runtime 两个包 +### 对外服务接口是 dsh 应用中的插件 -确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: +确定性服务接口由打包后的 `dsh` 应用选择为插件: - [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`packages/sdk/python-runtime`](../../../../packages/sdk/python-runtime/README.zh.md)(`@deepseek-ai/dsh-sdk-python-runtime`):私有打包入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归打包入口所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`apps/cli`](../../../../apps/cli/README.zh.md)(`@deepseek-ai/dsh`):打包后的应用入口;其 `sdk` profile 挂载 `dsh-sdk-jsonrpc-server`,CLI 负责环境分层、profile 组合、stdin/signal 关闭与进程退出。 -配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 +Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 patch 文件。缺失 home、profile、bundle 或 server 配置项都会明确失败;不存在外部完整配置回退。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该应用接口。 ### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml index 6f7a67dd4d..d3d02c9f70 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.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-11-repository-naming-contract-and-rename-ledger.md -2026-08-11-repository-naming-contract-and-rename-ledger.md: 2de88df5f1a5037d32daa9a8ee9cd1edfc60528a -2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 0cbbbee561358614da0b20e49c610fbee1d4e537 +2026-08-11-repository-naming-contract-and-rename-ledger.md: 403a7288bb3af0eff09b229e7d399b94492ae6c0 +2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 644e1c0bebfc43414e9e67def8d6c42acddf4c72 diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md index 2de88df5f1..403a7288bb 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md @@ -274,11 +274,11 @@ Keep MCP, Todo, and the Plan Mode package, key, events, and tool names. This dec | `E2BSandboxService` | `E2BRuntime` | The class creates, reuses, and disposes the E2B execution environment used by filesystem and subprocess adapters. It is broader than one sandbox handle and narrower than a generic owner. Keep `@deepseek-ai/dsh-e2b`, `ctx.e2b`, and the `e2b/` group. | | `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | The package is the Host plugin that serves the frontend assets. The prefix distinguishes it from frontend application code. | | `PluginInventoryService` | `PluginInventoryGateway` | The class is a Remote-only adapter from the live Loader tree to the `pluginInventory/list` RPC. It owns no same-process service, cache, history, or mutation path. `Gateway` states the role that exists. | -| `@deepseek-ai/dsh-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | The private package carries only the temporarily separate Python SDK runtime; the [single dsh launcher decision](2026-08-22-single-dsh-application-launcher.md) owns its application-boundary change. | -| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | The carrier is production packaging infrastructure for the Python SDK, not a demo bundle. | -| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | The direct-config runnable example belongs specifically to the Python SDK exception. | +| `@deepseek-ai/dsh-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-python-runtime` | removed | The Python runtime packages the existing `@deepseek-ai/dsh` CLI and its `sdk` profile; a private application package would recreate a second launcher. | +| `packages/examples/jsonrpc-demo/`, `packages/sdk/python-runtime/` | removed | The Python runtime wheel's closure manifest owns packaging without a separate application package. | +| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | The example demonstrates Python use of the `sdk` profile and ordered patches. | | `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | The package is the ACP profile's application bundle, not a standalone demo bin. | -| Deploy-root manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | The manifest defines the private Python runtime dependency closure. The Python-visible executable basename remains fixed until its documented profile migration. | +| Deploy-root manifests `dsh-jsonrpc-agent-pkg`, `dsh-sdk-python-runtime-closure` | `dsh-python-runtime-closure` | The zero-code manifest defines the Python runtime wheel's complete `dsh` dependency closure without naming a separate SDK application. | | `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | The application is the web frontend. Keep its physical `apps/web/` folder. | Keep atomic-write, brand, native-command, timeout utility, directory-picker, `dsh-base`, `dsh-web-app`, `dsh-sdk-app`, `dsh-acp-app`, app boot, CLI names, and the `headless` package, bundle, and example identity. `headless` is the intended product essence and may later support more than one-shot execution. diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md index 0cbbbee561..644e1c0beb 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md @@ -274,11 +274,11 @@ PascalCase 标识符中的首字母缩略词使用首字母大写格式:`Ui` | `E2BSandboxService` | `E2BRuntime` | 该类创建、复用和释放文件系统与子进程适配器所使用的 E2B 执行环境。它比单个沙箱句柄的职责更广,又比通用所有者更具体。保留 `@deepseek-ai/dsh-e2b`、`ctx.e2b` 和 `e2b/` 组。 | | `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | 该包是提供前端资源的 Host 插件。此前缀可将它与前端应用代码区分开。 | | `PluginInventoryService` | `PluginInventoryGateway` | 该类只负责把实时 Loader 树适配到 `pluginInventory/list` RPC。它不拥有同进程服务、缓存、历史或修改路径。`Gateway` 准确说明现有角色。 | -| `@deepseek-ai/dsh-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | 该私有包只承载暂时独立的 Python SDK 运行时;其应用边界变更由[单一 dsh 启动器决策](2026-08-22-single-dsh-application-launcher.zh.md)负责。 | -| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | 该载体是 Python SDK 的生产打包基础设施,不是演示组合包。 | -| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | 直读配置的可运行示例专属于 Python SDK 例外。 | +| `@deepseek-ai/dsh-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-python-runtime` | 已删除 | Python 运行时打包现有 `@deepseek-ai/dsh` CLI 与其 `sdk` profile;私有应用包会重新产生第二个启动器。 | +| `packages/examples/jsonrpc-demo/`、`packages/sdk/python-runtime/` | 已删除 | Python 运行时 wheel 的闭包 manifest 负责打包,无需独立应用包。 | +| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | 该示例演示 Python 使用 `sdk` profile 与有序 patch。 | | `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | 该包是 ACP profile 的应用组合包,不是独立 demo bin。 | -| 部署根 manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | 该 manifest 定义私有 Python 运行时依赖闭包。面向 Python 的可执行文件基本名称保持不变,直至完成已记录的 profile 迁移。 | +| 部署根 manifest `dsh-jsonrpc-agent-pkg`、`dsh-sdk-python-runtime-closure` | `dsh-python-runtime-closure` | 该零代码 manifest 定义 Python 运行时 wheel 的完整 `dsh` 依赖闭包,不再命名独立 SDK 应用。 | | `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | 该应用是 Web 前端。保留其物理目录 `apps/web/`。 | 保留 atomic-write、brand、native-command、timeout 实用工具、目录选择器、`dsh-base`、`dsh-web-app`、`dsh-sdk-app`、`dsh-acp-app`、应用启动、CLI(命令行界面)名称,以及 `headless` 包、组合包和示例身份。`headless` 是预期的产品本质,未来也可以支持不止一次性执行。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml new file mode 100644 index 0000000000..2e98550ed0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md +2026-08-23-python-sdk-dsh-profile-runtime.md: 400915155460598e754c2f81d60b26e693ff438a +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: a3c9074817d1c1e9b3f59fbe5fb02910d20e02f4 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md new file mode 100644 index 0000000000..4009151554 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -0,0 +1,53 @@ +# Agent Note: Python SDK runtime through the dsh profile launcher + +Status: implemented + +English | [中文](2026-08-23-python-sdk-dsh-profile-runtime.zh.md) + +## Problem + +The Python SDK distributed a private Node application that booted a complete external `cordis.yml`, while every other supported application entered through `dsh` profiles. That exception duplicated environment loading, configuration ownership, plugin resolution, shutdown, artifact names, and test paths. It also made SDK customization an all-or-nothing application tree: a caller replacing one plugin had to own the JSON-RPC server and every unrelated deployment row. + +A normal profile cannot be adopted only at the Python wrapper. The runtime executable must contain the `dsh` CLI, shipped profile and bundle files, native libraries, and a module-resolution path that works when profile files and external plugins live outside pkg's virtual filesystem. + +## Decision + +### One application launcher + +The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path. + +The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, and timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path. + +Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME` in the child environment. The SDK never discovers `~/.dsh`. The selected home consistently owns profiles, external plugins, credentials, settings, and sessions. + +### Plugin customization + +Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback. + +The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. + +### Executable packaging + +The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. + +Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy mirrors the source package's explicit runtime exports and identity and re-exports its virtual module URLs. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. + +The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. + +## Existing decisions and supersession + +This decision implements and supersedes the Python exception and deferred-migration sections of [the single dsh application launcher](2026-08-22-single-dsh-application-launcher.md). It supersedes the private application, external complete-config, artifact-name, and customization facts in [the single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md), which remains authoritative for pkg/SEA, wheel construction, native target validation, and publication. No active note is fully superseded, so none is archived. + +## Alternatives considered + +**Keep complete `cordis.yml` as an advanced escape hatch.** Rejected because it preserves a second application assembly and lets a caller bypass profile environment, plugin, and shutdown ownership. + +**Silently use `~/.dsh` for compatibility.** Rejected because an SDK process must not inherit a person's plugins, credentials, settings, or sessions without an explicit choice. + +**Copy the virtual dependency tree into every home.** Rejected because it duplicates hundreds of megabytes and loads a second Cordis instance. Export-preserving proxies are small and retain module identity. + +**Bundle pnpm and Node package management into every SDK launch.** Rejected because installed plugins are deployment state, not per-turn runtime work. Only `dsh plugin` needs the external package manager. + +## Consequences + +Python callers configure the same profile vocabulary as TypeScript and direct CLI users, and arbitrary external bundles can extend an SDK profile without replacing the application tree. Homes must now be selected explicitly, complete-config and `session_root` parameters are unavailable, and the executable includes shared-library assets plus profile-module proxies. The stronger installed-wheel CI makes those package, profile, native, and provider paths release requirements rather than source-only assumptions. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md new file mode 100644 index 0000000000..a3c9074817 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 通过 dsh profile 启动器运行 Python SDK 运行时 + +Status: implemented + +[English](2026-08-23-python-sdk-dsh-profile-runtime.md) | 中文 + +## 问题 + +Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml`;其他所有受支持应用都从 `dsh` profile 进入。该例外重复了环境加载、配置所有权、插件解析、关闭流程、产物命名与测试路径。它还把 SDK 自定义变成全量替换应用树:只想替换一个插件的调用方也必须拥有 JSON-RPC server 和所有无关 deployment 配置项。 + +仅修改 Python wrapper 无法采用普通 profile。运行时可执行程序必须包含 `dsh` CLI、随附 profile 与 bundle 文件、原生库,以及在 profile 文件和外部插件位于 pkg 虚拟文件系统之外时仍可工作的模块解析路径。 + +## 决策 + +### 一个应用启动器 + +运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。 + +公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择与 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。 + +每次 Python 启动都要求显式 `dsh_home`,或子进程环境中的非空 `DSH_HOME`。SDK 绝不会发现 `~/.dsh`。所选 home 统一拥有 profile、外部插件、凭据、设置与会话。 + +### 插件自定义 + +持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退。 + +运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 + +### 可执行程序打包 + +零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 + +普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理镜像源包的显式运行时 exports 与身份,并重新导出其虚拟模块 URL。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 + +已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 + +## 既有决策与取代关系 + +本决策实现并取代[单一 dsh 应用启动器](2026-08-22-single-dsh-application-launcher.zh.md)中的 Python 例外与延后迁移章节。它取代[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的私有应用、外部完整配置、产物名称与自定义事实;后者继续负责 pkg/SEA、wheel 构建、原生目标验证与发布。没有任何 active note 被完全取代,因此无需归档。 + +## 考虑过的替代方案 + +**把完整 `cordis.yml` 保留为高级逃生口。** 不予采用,因为它会保留第二套应用组装,并允许调用方绕过 profile 的环境、插件与关闭所有权。 + +**为兼容性静默使用 `~/.dsh`。** 不予采用,因为 SDK 进程不应在没有显式选择时继承个人插件、凭据、设置或会话。 + +**把虚拟依赖树复制到每个 home。** 不予采用,因为这会重复数百 MB,并加载第二个 Cordis 实例。保留 exports 的代理很小,而且维持模块身份。 + +**把 pnpm 与 Node 包管理纳入每次 SDK 启动。** 不予采用,因为已安装插件属于 deployment 状态,而不是逐轮运行时工作。只有 `dsh plugin` 需要外部包管理器。 + +## 结果 + +Python 调用方使用与 TypeScript 和直接 CLI 用户相同的 profile 词汇,任意外部 bundle 可以扩展 SDK profile,而无需替换应用树。调用方现在必须显式选择 home,完整配置与 `session_root` 参数不可用,可执行程序则包含共享库资源和 profile 模块代理。更强的 installed-wheel CI 将包、profile、原生与提供方路径变成发布要求,而不是仅在源码中成立的假设。 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml index 93a20e387f..635b5df7af 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-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/testing/2026-08-23-installed-python-wheel-black-box-ci.md -2026-08-23-installed-python-wheel-black-box-ci.md: f2b5bd0edeb02a5d72e8010c62c3cfb59ee95c5d -2026-08-23-installed-python-wheel-black-box-ci.zh.md: fb0f5fb2da676f1a24a2630bd45f005f46a3095e +2026-08-23-installed-python-wheel-black-box-ci.md: a2fd4134d7bff0e74aa2d1afc3590e9cdd90809e +2026-08-23-installed-python-wheel-black-box-ci.zh.md: 203440ed0f6bbe84257474dd69400a64bc480cd3 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md index f2b5bd0ede..a2fd4134d7 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md @@ -18,7 +18,7 @@ The black-box harness rejects a non-venv process, repository-relative working di ### Keyless behavior -Every target runs the complete packaged-runtime scenario set after installation. A local SSE model keeps outputs deterministic while the public SDK exercises the default configuration, an external complete configuration, persistent PTY and editor behavior, worker-thread code and workflow execution, ripgrep-backed search, external stdio MCP discovery and execution, model-visible and durable snapshots, JSONL/Zstandard persistence, direct JSON-RPC, and shutdown. A restart snapshot launches two complete SDK runtime processes against one persistence root and pins their isolated model histories, high-level results, and separate durable logs. The installed run replaces the source-SDK pre-wheel run; the executable and wheel are tested together once rather than maintaining two behavior inventories. +Every target runs the complete packaged-runtime scenario set after installation. A local SSE model keeps outputs deterministic while the public SDK exercises the default SDK profile, ordered patch overlays, external bundle installation through `dsh plugin`, persistent PTY and editor behavior, worker-thread code and workflow execution, ripgrep-backed search, external stdio MCP discovery and execution, model-visible and durable snapshots, Zstandard persistence, direct JSON-RPC, and shutdown. A restart snapshot launches two complete SDK runtime processes against one persistence root and pins their isolated model histories, high-level results, and separate durable logs. The installed run replaces the source-SDK pre-wheel run; the executable and wheel are tested together once rather than maintaining two behavior inventories. Linux additionally retains its manylinux 2.28 clean-install smoke and GLIBC checks. macOS retains deployment-target and native helper checks. These platform constraints supplement the common black-box behavior rather than substituting for it. @@ -34,7 +34,7 @@ The pull-request `python-runtime` job calls the reusable builder for Linux x64, ## Existing decisions and supersession -This decision supersedes the single-target topology in the archived [required Python runtime pull-request validation](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md) while retaining its requirement that the real executable, snapshots, wheels, and clean installation meet before merge. The [single-file Python SDK runtime distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) remains authoritative for SEA packaging, the closed dependency set, native sidecars, wheel tags, and release artifacts. +This decision supersedes the single-target topology in the archived [required Python runtime pull-request validation](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md) while retaining its requirement that the real executable, snapshots, wheels, and clean installation meet before merge. The [Python SDK dsh profile runtime](../architecture/2026-08-23-python-sdk-dsh-profile-runtime.md) owns the launched application and customization surface; the [single-file Python SDK runtime distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) remains authoritative for SEA packaging, native sidecars, wheel tags, and release artifacts. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md index fb0f5fb2da..203440ed0f 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md @@ -18,7 +18,7 @@ Python SDK 单元测试驱动 fake peer,而打包运行时工作流可以在 ### Keyless 行为 -每个目标都会在安装后运行完整的打包运行时场景。一个本地 SSE mock 模型提供确定性输出,公开 SDK 则覆盖默认配置、外部完整配置、持久 PTY 与 editor 行为、worker thread 代码与 workflow 执行、基于 ripgrep 的搜索、外部 stdio MCP 发现与执行、模型可见及持久化快照、JSONL/Zstandard 持久化、直接 JSON-RPC 与关闭。Restart 快照针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。安装后运行取代 wheel 构建前的源码 SDK 运行,因此可执行文件与 wheel 包共同接受一次验证,而不是维护两套行为清单。 +每个目标都会在安装后运行完整的打包运行时场景。一个本地 SSE mock 模型提供确定性输出,公开 SDK 则覆盖默认 SDK profile、有序 patch overlay、通过 `dsh plugin` 安装外部 bundle、持久 PTY 与 editor 行为、worker thread 代码与 workflow 执行、基于 ripgrep 的搜索、外部 stdio MCP 发现与执行、模型可见及持久化快照、Zstandard 持久化、直接 JSON-RPC 与关闭。Restart 快照针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。安装后运行取代 wheel 构建前的源码 SDK 运行,因此可执行文件与 wheel 包共同接受一次验证,而不是维护两套行为清单。 Linux 另外保留 manylinux 2.28 干净安装冒烟测试与 GLIBC 检查。macOS 保留部署目标与原生 helper 检查。这些平台约束补充共同黑盒行为,不能替代它。 @@ -34,7 +34,7 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ## Existing decisions and supersession -本决策取代已归档的[必需 Python 运行时拉取请求验证](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md)中的单目标拓扑,同时保留真实可执行文件、快照、wheel 包与干净安装必须在合并前相遇的要求。[单文件 Python SDK 运行时 distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)仍负责 SEA 打包、封闭依赖集合、原生 sidecar、wheel 包标签与发布产物。 +本决策取代已归档的[必需 Python 运行时拉取请求验证](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md)中的单目标拓扑,同时保留真实可执行文件、快照、wheel 包与干净安装必须在合并前相遇的要求。[Python SDK dsh profile 运行时](../architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责启动应用与自定义接口;[单文件 Python SDK 运行时 distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)继续负责 SEA 打包、原生 sidecar、wheel 包标签与发布产物。 ## Alternatives considered diff --git a/AGENTS.md b/AGENTS.md index 3fa1ce77e4..1fd35bb6c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// acp/ automation-only Agent Client Protocol server interaction/ approval/interaction capabilities, permission, commands, ask-user boot/ shared profile/application boot glue - sdk/ JSON-RPC protocol, server, TypeScript client, and private Python carrier + sdk/ JSON-RPC protocol, server, and TypeScript client examples/ reusable demo bundles (agent-spine) experimental/ private prototypes excluded from official releases support/ dev/test infrastructure diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 977871533e..c23418172b 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: d59b8264093dafb0160893c942371a4daa942c14 -README.zh.md: 6a209f5ad64b38c138ae1712a05ed9e840d9a74f +README.md: b64861ce68b4c6de8edc3142fb95d83f1d395d15 +README.zh.md: a42a1b0539703dc03adf0ac53e8b5819940429a2 diff --git a/apps/cli/README.md b/apps/cli/README.md index d59b826409..b64861ce68 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. +The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command and launches its `sdk` profile. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. ## Entry modes diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 6a209f5ad6..a42a1b0539 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 +`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。Python 运行时 wheel 会打包同一个命令,并启动其 `sdk` profile。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 ## 入口模式 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 5ed7b32789..f432cc953b 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 0f407afa3b06d144681550d5096bf96c498e6451 -README.zh.md: bf4dc4ca9f49c1801d108234411123de459c0444 +README.md: 2692731ecdfb4a3f798ca0506954289f2c9bdca8 +README.zh.md: 0a2c0a14151f7b774ed00aab808c7313716c131a diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0f407afa3b..2692731ecd 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -8,7 +8,7 @@ This reference defines the profile, web-alias, plugin-management, and config-dum `dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index bf4dc4ca9f..0a2c0a1415 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -8,7 +8,7 @@ `dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 -组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。该目录为 dsh 安装中的应用和组合包所依赖的每个包各维护一个符号链接,并在每次启动时修复这些链接。 +组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。 `web`、`headless`、`sdk` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 7f2bc49362..fe84ec6adf 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: e37f2321377242803fb89f0a2258682e6c52801c -architecture.zh.md: 69abbcdf52654ecbce6549d25ee089b937228123 +architecture.md: 362a3758fabdb67ea883bcf84ea5df4cf4e77b0f +architecture.zh.md: 421c38ad5b90db7b7ac1b5fe6bb10ccc0793feb5 diff --git a/docs/architecture.md b/docs/architecture.md index e37f232137..362a3758fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Every supported Node application starts at the `dsh` CLI with a named profile. T Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. -The packaged Python SDK runtime is the sole temporary application exception. Its private [`dsh-sdk-python-runtime`](../packages/sdk/python-runtime/README.md) carrier and `dsh-sdk-python-runtime-closure` deploy manifest preserve the current Python API, wire, default `cordis.yml`, environment variables, wheel names, `dsh-jsonrpc-agent-pkg--` executables, sidecars, and platform set. A later Python migration will launch `dsh --profile sdk`, delete the private direct-config carrier, and then rename that executable family to `deepseek-harness-sdk-runtime--`. +The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. ## Core packages diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 69abbcdf52..421c38ad5b 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -44,7 +44,7 @@ dsh --profile web --dump-config Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 -打包后的 Python SDK 运行时是唯一的临时应用例外。其私有 [`dsh-sdk-python-runtime`](../packages/sdk/python-runtime/README.zh.md) 载体与 `dsh-sdk-python-runtime-closure` 部署 manifest 保持当前 Python API、协议格式、默认 `cordis.yml`、环境变量、wheel 包名称、`dsh-jsonrpc-agent-pkg--` 可执行文件、伴随文件及平台集合不变。后续 Python 迁移会改为启动 `dsh --profile sdk`、删除私有直读配置载体,然后把该可执行文件族重命名为 `deepseek-harness-sdk-runtime--`。 +Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端以显式 Harness home 启动 `dsh --profile sdk`。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 ## 核心包 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index de34a153cf..9dc39c909c 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: 46228907ea4241e69a4439c2dc1c1786de94eaff -config-catalog.zh.md: ed09a3d44f93815ab667ef4613249a33306d9899 +config-catalog.md: acd32b7e76180f13956ec8a54ca16d0ef99bf203 +config-catalog.zh.md: eacca1575191cff6dde93cf80e5ac576e47f485c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 46228907ea..acd32b7e76 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3389,7 +3389,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) - `@deepseek-ai/dsh-sdk-protocol` ([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) -- `@deepseek-ai/dsh-sdk-python-runtime` ([`packages/sdk/python-runtime/src/index.ts`](../packages/sdk/python-runtime/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-in-process-driver` ([`packages/subagent/subagent-in-process-driver/src/index.ts`](../packages/subagent/subagent-in-process-driver/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ed09a3d44f..eacca15751 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3390,7 +3390,6 @@ export interface Config { - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) - `@deepseek-ai/dsh-sdk-protocol`([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) -- `@deepseek-ai/dsh-sdk-python-runtime`([`packages/sdk/python-runtime/src/index.ts`](../packages/sdk/python-runtime/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-in-process-driver`([`packages/subagent/subagent-in-process-driver/src/index.ts`](../packages/subagent/subagent-in-process-driver/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8ba88343ec..e5f37e4a8c 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: aeb35195f3a095b9de694f164dc1111acf5c8197 -module-graph.zh.md: ef3bc3fbce9cad37542eeea6adf936dd70e6581b +module-graph.md: d401df81c59de27a0171750d92031c40939c6564 +module-graph.zh.md: 8186217711a2ca320017dc0473227f7a9cf59b1a diff --git a/docs/module-graph.md b/docs/module-graph.md index aeb35195f3..d401df81c5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -275,7 +275,6 @@ flowchart TD pkg_sdk_client["sdk-client"] pkg_sdk_jsonrpc_server["sdk-jsonrpc-server"] pkg_sdk_protocol["sdk-protocol"] - pkg_sdk_python_runtime["sdk-python-runtime"] end subgraph group_session["packages/session"] pkg_session_checkpoint_policy["session-checkpoint-policy"] @@ -381,7 +380,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_sandbox_windows_acl --> pkg_invariants - pkg_sdk_python_runtime --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_win32_process --> pkg_invariants @@ -1672,7 +1670,6 @@ flowchart TD | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`sdk-python-runtime`](../packages/sdk/python-runtime) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ef3bc3fbce..8186217711 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -277,7 +277,6 @@ flowchart TD pkg_sdk_client["sdk-client"] pkg_sdk_jsonrpc_server["sdk-jsonrpc-server"] pkg_sdk_protocol["sdk-protocol"] - pkg_sdk_python_runtime["sdk-python-runtime"] end subgraph group_session["packages/session"] pkg_session_checkpoint_policy["session-checkpoint-policy"] @@ -383,7 +382,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_sandbox_windows_acl --> pkg_invariants - pkg_sdk_python_runtime --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_win32_process --> pkg_invariants @@ -1674,7 +1672,6 @@ flowchart TD | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`sdk-python-runtime`](../packages/sdk/python-runtime) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index f2299b6512..bbb6cff7ec 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/python-sdk.md -python-sdk.md: 21c7a908a8524b16baf8f98453746f59b5d5efc8 -python-sdk.zh.md: bb883f6f3799225bda590a80883063ecb29e15b4 +python-sdk.md: 4fbc6b343714b232b90a7954b35bf6863716281b +python-sdk.zh.md: 51637ed986d298d46633891077bf2a9d27c35bdd diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 21c7a908a8..4fbc6b3437 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -2,7 +2,7 @@ English | [中文](python-sdk.zh.md) -This tutorial is the programmatic alternative to the Web UI. It installs the published Python SDK, runs a checked-in agent composition, and shows how to call the same API from your own program. +This tutorial installs the published Python SDK, runs the checked-in minimal profile overlay, and shows how to customize the same `dsh` profile from your own program. ## Prerequisites @@ -10,12 +10,10 @@ This tutorial is the programmatic alternative to the Web UI. It installs the pub - Git - Linux x64, Linux arm64, or macOS 14 or newer on arm64 - A DeepSeek-compatible API endpoint and credential -- An isolated workspace that the agent may modify +- An isolated workspace and an isolated Harness home ## Install the SDK -Clone the repository for its runnable example, create a virtual environment, and install the SDK with its same-version bundled runtime: - ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness @@ -24,51 +22,48 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` -The installed runtime needs no system Node.js. Repository contributors who need to build the runtime or wheels from source should use the [Python contributor workflows](../../../python/development.md). +The installation includes a matching native runtime wheel and the `dsh` command. Normal SDK execution needs no system Node.js. Repository contributors who build the artifacts should use the [Python contributor workflow](../../../python/development.md). ## Run the checked-in example -Set the credential in the environment. Set `DEEPSEEK_BASE_URL` as well when the model is served by an OpenAI-compatible proxy rather than the default DeepSeek endpoint. +Export the credential and, when needed, a compatible proxy endpoint: ```sh export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 -# export DSH_MODEL=deepseek-v4-flash -# export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.' ``` -Run one task against an isolated workspace and session directory: +Run one task with explicit workspace and home paths: ```sh python examples/python-sdk-agent/minimal.py \ - --workspace /absolute/path/to/workspace \ - --session-root /absolute/path/to/sessions \ + --workspace /absolute/path/to/disposable-workspace \ + --dsh-home /absolute/path/to/example-dsh-home \ --session-id example-001 \ "Inspect the repository and fix the failing tests." ``` -The script prints the final assistant response. The session directory receives a JSONL log containing the assembled model requests and tool calls. +The script prints the final assistant response. The selected home receives the generated `sdk` profile, settings, credentials if you add them, installed plugins, and Zstandard session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. -## Use the SDK in your own program - -The checked-in example is a thin wrapper around this SDK call: +## Use the SDK in your program ```python from pathlib import Path from deepseek_harness import DeepSeekHarness -config = Path("examples/python-sdk-agent/minimal.cordis.yml").resolve() -workspace = Path("/absolute/path/to/workspace").resolve() -sessions = Path("/absolute/path/to/sessions").resolve() +workspace = Path("/absolute/path/to/disposable-workspace").resolve() +dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() +patch = Path("examples/python-sdk-agent/minimal.patch.yml").resolve() with DeepSeekHarness( provider="deepseek-official", model="deepseek-v4-flash", max_tokens=49_152, cwd=str(workspace), - session_root=str(sessions), - cordis=str(config), + dsh_home=str(dsh_home), + profile="sdk", + patches=(str(patch),), ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", @@ -78,9 +73,23 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` starts the bundled runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same durable conversation. +The SDK starts the bundled `dsh --profile sdk` process lazily and reuses it until context-manager exit. The profile, its persistent patch, the home patch, and the ordered `patches` tuple form the application configuration. There is no separate Python runtime bin or complete-config option. -## Understand the example composition +## Install or define plugins + +Use `dsh plugin` for dependencies and bundle layers that should persist in this home: + +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh --profile sdk --dump-default-config >/dev/null +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +The first command initializes the shipped SDK profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. + +Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. + +## Understand the minimal overlay | Property | Value | |---|---| @@ -90,15 +99,10 @@ print(result.final_response) | Bash timeout | 300 seconds | | Editor output limit | 16,000 characters | | Context compaction | Disabled | -| Filesystem | Bare local backend; absolute editor paths may address any path visible to the runtime process | -| Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` | +| Session persistence | Zstandard JSONL under `/sessions` | -The composition omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, compaction, and every other model-facing plugin. Sandbox-policy facts are logged as runtime user context rather than appended to the system prompt. +The overlay removes runtime-context prompt messages and most default tools while retaining the SDK application's protocol and persistence. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. -## Choose workspace and session IDs +Use a fresh home when profiles, plugins, credentials, settings, and sessions must be isolated. Use a fresh session id for independent work; reuse a harness, home, and id only to continue the same durable conversation and session-owned resources. -`cwd` selects the workspace available to the agent, while `session_root` stores session logs and state. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same conversation and persistent shell state. - -The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate, so this composition does not support Windows agents. - -The [`python-sdk-agent` example reference](../../../examples/python-sdk-agent/README.md) owns the exact composition. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, runtime selection, and configuration; the [Cordis primer](../../cordis-primer.md) covers composition syntax. +The [example reference](../../../examples/python-sdk-agent/README.md) owns the checked-in overlay. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, and low-level behavior; the [dsh CLI reference](../../../apps/cli/reference/README.md) covers profile layering. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index bb883f6f37..51637ed986 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -1,21 +1,19 @@ -# Python SDK 快速上手 +# Python SDK 入门 [English](python-sdk.md) | 中文 -本教程介绍 Web UI 之外的程序化使用方式:安装已发布的 Python SDK、运行仓库内置的 agent(智能体)组合,并在自己的程序中调用同一套 API。 +本教程安装已发布的 Python SDK,运行检入的极简 profile overlay,并说明如何从自己的程序自定义同一个 `dsh` profile。 -## 前置要求 +## 前置条件 - Python 3.10 或更高版本 - Git -- Linux x64、Linux arm64 或 macOS 14 或更高版本的 arm64 -- DeepSeek 兼容的 API 端点与凭据 -- agent 可以修改的隔离 workspace +- Linux x64、Linux arm64,或 arm64 上的 macOS 14 或更高版本 +- DeepSeek 兼容的 API endpoint 与凭据 +- 隔离的 workspace 与隔离的 Harness home ## 安装 SDK -克隆仓库以使用其中的可运行示例,创建虚拟环境,并安装 SDK 及其同版本内置运行时: - ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness @@ -24,51 +22,48 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` -安装后的运行时不需要系统提供 Node.js。需要从源码构建运行时或 wheel 包的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.zh.md)。 +安装内容包含匹配的原生运行时 wheel 与 `dsh` 命令。普通 SDK 运行不需要系统 Node.js。需要构建产物的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.zh.md)。 -## 运行仓库内置示例 +## 运行检入示例 -请在环境中设置凭据。如果模型不是由默认 DeepSeek 端点提供,而是通过 OpenAI 兼容代理提供,还需要设置 `DEEPSEEK_BASE_URL`。 +导出凭据;使用兼容代理时再设置 endpoint: ```sh export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 -# export DSH_MODEL=deepseek-v4-flash -# export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.' ``` -针对隔离的 workspace 和会话目录运行一个任务: +使用显式 workspace 与 home 路径运行一个任务: ```sh python examples/python-sdk-agent/minimal.py \ - --workspace /absolute/path/to/workspace \ - --session-root /absolute/path/to/sessions \ + --workspace /absolute/path/to/disposable-workspace \ + --dsh-home /absolute/path/to/example-dsh-home \ --session-id example-001 \ "Inspect the repository and fix the failing tests." ``` -脚本会打印 assistant 的最终回复。会话目录会收到 JSONL 日志,其中包含组装后的模型请求与工具调用。 +脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk` profile、设置、调用方添加的凭据、已安装插件,以及 `sessions/` 下的 Zstandard 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 -## 在自己的程序中使用 SDK - -仓库内置示例是以下 SDK 调用的轻量包装: +## 在程序中使用 SDK ```python from pathlib import Path from deepseek_harness import DeepSeekHarness -config = Path("examples/python-sdk-agent/minimal.cordis.yml").resolve() -workspace = Path("/absolute/path/to/workspace").resolve() -sessions = Path("/absolute/path/to/sessions").resolve() +workspace = Path("/absolute/path/to/disposable-workspace").resolve() +dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() +patch = Path("examples/python-sdk-agent/minimal.patch.yml").resolve() with DeepSeekHarness( provider="deepseek-official", model="deepseek-v4-flash", max_tokens=49_152, cwd=str(workspace), - session_root=str(sessions), - cordis=str(config), + dsh_home=str(dsh_home), + profile="sdk", + patches=(str(patch),), ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", @@ -78,27 +73,36 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` 会延迟启动内置运行时,并持续复用,直至退出上下文管理器。复用同一个 harness 与 session id 会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。独立任务应使用新的 session id;只有下一次调用需要延续同一段持久化对话时,才复用原有 id。 +SDK 会延迟启动内置的 `dsh --profile sdk` 进程,并复用到上下文管理器退出。Profile、其持久 patch、home patch 与有序 `patches` tuple 共同组成应用配置。不存在独立 Python 运行时 bin 或完整配置选项。 -## 了解示例组合 +## 安装或定义插件 + +需要在该 home 中持久保存依赖与 bundle 层时,使用 `dsh plugin`: + +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh --profile sdk --dump-default-config >/dev/null +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +第一个命令初始化随附的 SDK profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 + +另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 + +## 理解极简 overlay | 属性 | 值 | |---|---| -| 系统提示词 | `DSH_SYSTEM_PROMPT`;未设置时使用 `You are a helpful software engineer assistant.` | -| `minimal.py` 使用的模型 | `--model`,其次为 `DSH_MODEL`,最后为 `deepseek-v4-flash` | +| 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` | +| `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` | | 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | | Bash 超时 | 300 秒 | -| 编辑器输出上限 | 16,000 个字符 | -| 上下文压缩 | 已关闭 | -| 文件系统 | 裸本地后端;编辑器使用绝对路径,可以访问运行时进程可见的任何路径 | -| 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL | +| Editor 输出上限 | 16,000 字符 | +| 上下文压缩 | 禁用 | +| 会话持久化 | `/sessions` 下的 Zstandard JSONL | -该组合省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具、上下文压缩和其他所有面向模型的插件。沙箱策略事实记录为运行时用户上下文,而不会追加到系统提示词中。 +该 overlay 会移除运行时上下文提示消息与大多数默认工具,但保留 SDK 应用的协议与持久化。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 -## 选择 workspace 与 session id +需要隔离 profile、插件、凭据、设置与会话时,应使用新的 home。独立工作应使用新的 session id;只有继续同一段持久对话和会话资源时,才同时复用 harness、home 与 id。 -`cwd` 用于选择 agent 可访问的 workspace,`session_root` 用于保存会话日志和状态。独立任务应使用新的 session id;只有下一次调用需要延续同一段对话和持久 shell 状态时,才复用原有 id。 - -该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该组合不支持 Windows agent。 - -准确的组合内容归 [`python-sdk-agent` 示例参考](../../../examples/python-sdk-agent/README.zh.md)所有。[Python SDK 参考](../../../python/sdk/README.zh.md)介绍生命周期、结果、通知、运行时选择和配置;[Cordis primer](../../cordis-primer.zh.md)介绍组合语法。 +[示例参考](../../../examples/python-sdk-agent/README.zh.md)定义检入 overlay。[Python SDK 参考](../../../python/sdk/README.zh.md)介绍生命周期、结果、通知与底层行为;[dsh CLI 参考](../../../apps/cli/reference/README.zh.md)介绍 profile 分层。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index d217628a89..1f9b897250 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-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 examples/python-sdk-agent/README.md -README.md: 279aa5bcf0e988168cc936fbd6d96b69e74a5873 -README.zh.md: d843d8be719349b24e0369a2177748f2b09e40d6 +README.md: e46765b0419b0b614163fa507cadef0e5c5a53b9 +README.zh.md: 13c297bdbf01b612369ded5909ec8b7f71bab26c diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index 279aa5bcf0..e46765b041 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -2,39 +2,41 @@ English | [中文](README.zh.md) -The unattended coding-agent composition for the Python SDK's bundled JSON-RPC runtime. It intentionally loads no terminal UI, console logger, approval UI, or user-questions tool because stdout belongs to the SDK protocol and turns are driven by the SDK. +Runnable Python SDK example over the sole application launcher, `dsh --profile sdk`. The Python client owns JSON-RPC stdio; the profile owns the agent composition, persistence, permissions, and plugins. -The model-facing tools are: +## Run the minimal agent -- `bash`, foreground only -- `read`, `write`, and `edit` -- `subagent`, using one foreground in-process spawn provider -- `todo_write` +Install `deepseek-harness-sdk`, export a model credential, then supply an isolated Harness home and workspace: -The surrounding runtime also loads JSONL session persistence and automatic context compaction. `maxTokensAsSuccess` keeps a token-limited model turn as an accepted evaluation result while preserving its `max-tokens` reason. +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +python examples/python-sdk-agent/minimal.py \ + --dsh-home /absolute/path/to/example-dsh-home \ + --workspace /absolute/path/to/disposable-workspace \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` -## Runtime environment +Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` and `--profile` override their script defaults. The selected home stores the generated profile and Zstandard session logs under `sessions/`; the script never reads `~/.dsh` implicitly. -| Variable | Purpose | -|---|---| -| `DEEPSEEK_API_KEY` | Credential passed to the OpenAI-compatible host endpoint | -| `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | -| `DSH_CWD` | Agent workspace for bash and filesystem tools | -| `DSH_CONTEXT_WINDOW` | Context capacity recorded for the `DSH_MODEL` catalog entry in the minimal variant | -| `DSH_MAX_TOKENS_AS_SUCCESS` | `true` (default) accepts token-limited results; `false` reports them as errors | -| `DSH_MODEL` | Default model used by `minimal.py`; `--model` takes precedence | -| `DSH_SESSION_ROOT` | JSONL session directory | -| `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | - -Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. - -## Minimal variant - -[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. `DSH_SYSTEM_PROMPT` selects its system prompt, with `You are a helpful software engineer assistant.` as the fallback. It suppresses every system-prompt runtime-context contribution for fresh sessions and mounts no context-compaction plugin. Its model-facing tools are exactly: +[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. It preserves the SDK application bundle but narrows model-visible behavior to: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, bare `fs-local` backend, danger-full-access policy for persistent Bash, and uncompressed JSONL persistence needed by the bundled runtime. Bash and absolute editor paths can modify any path available to the runtime process, so run this variant only against a disposable checkout or container. The persistent PTY requires a POSIX terminal environment and is not a Windows agent interface. +The patch omits Harness identity and runtime-context messages, local instruction discovery, skills, compaction, plan/goal/task/web/subagent/workflow tools, and the profile's one-shot Bash. It inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. -[`minimal.py`](minimal.py) runs the composition through the Python SDK and uses `DSH_MODEL` as its default model. The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) covers installation, execution, workspace selection, and session identity; the [SDK reference](../../python/sdk/README.md) owns runtime lifecycle and result semantics. +This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. + +## Add plugins + +Use the runtime wheel's `dsh` command against the same explicit home for persistent profile changes: + +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +The Python call can also pass additional absolute patch paths in `patches=(...)`; later files win. A selected profile must retain `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Complete standalone Cordis files in this directory remain test fixtures for lower-level composition coverage; they are not Python SDK launch interfaces. + +See the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) and [SDK reference](../../python/sdk/README.md). diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index d843d8be71..13c297bdbf 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -2,39 +2,41 @@ [English](README.md) | 中文 -面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、控制台日志记录器、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 +基于唯一应用启动器 `dsh --profile sdk` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;profile 负责 agent 组合、持久化、权限与插件。 -面向模型的工具为: +## 运行极简 agent -- `bash`,仅前台 -- `read`、`write` 和 `edit` -- `subagent`,使用一个在进程内以前台方式运行的 spawn 提供方 -- `todo_write` +安装 `deepseek-harness-sdk`、导出模型凭据,然后提供隔离的 Harness home 与 workspace: -周边运行时还加载 JSONL 会话持久化和自动上下文压缩(context compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +python examples/python-sdk-agent/minimal.py \ + --dsh-home /absolute/path/to/example-dsh-home \ + --workspace /absolute/path/to/disposable-workspace \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` -## 运行时环境 +兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 与 `--profile` 会覆盖脚本默认值。所选 home 保存生成的 profile,并在 `sessions/` 下保存 Zstandard 会话日志;脚本绝不会隐式读取 `~/.dsh`。 -| 变量 | 用途 | -|---|---| -| `DEEPSEEK_API_KEY` | 传给 OpenAI 兼容宿主端点的凭据 | -| `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | -| `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | -| `DSH_CONTEXT_WINDOW` | 极简变体中为 `DSH_MODEL` 目录项记录的上下文容量 | -| `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | -| `DSH_MODEL` | `minimal.py` 使用的默认模型;`--model` 优先 | -| `DSH_SESSION_ROOT` | JSONL 会话目录 | -| `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | +[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。它保留 SDK 应用 bundle,但将模型可见行为收窄为: -通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 +- agent 所有的持久 `bash` +- 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -## 极简变体 +该 patch 会省略 Harness 身份与运行时上下文消息、本地指令发现、skill、compaction,以及 plan/goal/task/web/subagent/workflow 工具和 profile 的单次 Bash。它插入本地 PTY 与持久 Bash provider,并把 editor 输出上限设为 16,000 字符。 -[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。`DSH_SYSTEM_PROMPT` 选择它的系统提示词,未设置时使用 `You are a helpful software engineer assistant.`。它为新建会话抑制每个 system-prompt runtime-context 贡献,且不挂载上下文压缩插件。面向模型的工具严格只有: +此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 -- 所有者作用域内持久化的 `bash` -- 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` +## 添加插件 -它组合了内置运行时所需的本地 PTY、裸 `fs-local` 后端、供持久 Bash 使用的 danger-full-access 策略,以及未压缩的 JSONL 持久化。Bash 和编辑器绝对路径可以修改运行时进程有权访问的任何路径,因此只能针对可丢弃的 checkout 或容器运行该变体。持久 PTY 需要 POSIX 终端环境,因此不适用于 Windows agent 接口。 +对同一个显式 home 使用运行时 wheel 提供的 `dsh` 命令,以进行持久 profile 变更: -[`minimal.py`](minimal.py)通过 Python SDK 运行该组合,并把 `DSH_MODEL` 作为默认模型。[Python SDK 教程](../../docs/user/guide/python-sdk.zh.md)介绍安装、运行、workspace 选择与 session 标识;[SDK 参考](../../python/sdk/README.zh.md)归属运行时生命周期与结果语义。 +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +Python 调用也可以在 `patches=(...)` 中传入更多绝对 patch 路径;后面的文件优先。所选 profile 必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项。本目录中的完整独立 Cordis 文件仍作为底层组合测试 fixture;它们不是 Python SDK 启动接口。 + +另见 [Python SDK 教程](../../docs/user/guide/python-sdk.zh.md)与 [SDK 参考](../../python/sdk/README.zh.md)。 diff --git a/examples/python-sdk-agent/cordis.snapshot.yml b/examples/python-sdk-agent/cordis.snapshot.yml index 23bc8c5402..a054ddbe4e 100644 --- a/examples/python-sdk-agent/cordis.snapshot.yml +++ b/examples/python-sdk-agent/cordis.snapshot.yml @@ -3,10 +3,9 @@ # key or network; every other entry remains shared. The replay provider # catalog claims the `deepseek-official` provider so the SDK server's `initialize` # finds it owned and never mounts the real-adapter fallback. The SDK snapshot -# suite passes this path explicitly through `DSH_CORDIS_CONFIG` (the -# jsonrpc-demo bin performs no DSH_SNAPSHOT config swap of its own), and -# `llm-replay` reads `DSH_SNAPSHOT_FILE` / `DSH_SNAPSHOT_CHILD_FILES` from the -# harness. Stdout remains reserved for JSON-RPC frames. +# suite selects this complete-config fixture explicitly, and `llm-replay` +# reads `DSH_SNAPSHOT_FILE` / `DSH_SNAPSHOT_CHILD_FILES` from the harness. +# It is not a Python launch interface. Stdout remains reserved for JSON-RPC. - id: base name: '@deepseek-ai/cordis-plugin-include' config: diff --git a/examples/python-sdk-agent/cordis.yml b/examples/python-sdk-agent/cordis.yml index 40878c58b2..6a04f5f42d 100644 --- a/examples/python-sdk-agent/cordis.yml +++ b/examples/python-sdk-agent/cordis.yml @@ -1,5 +1,6 @@ -# Unattended coding-agent deployment for the bundled dsh-jsonrpc-agent runtime. -# stdout is reserved for JSON-RPC; do not add a console logger or terminal UI. +# Complete JSON-RPC composition fixture for lower-level Loader and SDK tests. +# Python users launch `dsh --profile sdk` and apply patches instead. +# Stdout is reserved for JSON-RPC; do not add a console logger or terminal UI. - id: sdk-jsonrpc-server name: '@deepseek-ai/dsh-sdk-jsonrpc-server' diff --git a/examples/python-sdk-agent/minimal.cordis.yml b/examples/python-sdk-agent/minimal.cordis.yml index fdf3a18e7a..f849b798a0 100644 --- a/examples/python-sdk-agent/minimal.cordis.yml +++ b/examples/python-sdk-agent/minimal.cordis.yml @@ -1,7 +1,6 @@ -# Complete unattended minimal-agent composition for the Python SDK. The model -# sees one deployment-selected system prompt and only the owner-scoped -# persistent Bash and string-replace editor tools. Runtime-context injection and -# context compaction are absent. +# Complete minimal-agent composition fixture for lower-level snapshot tests. +# Python users apply `minimal.patch.yml` to `dsh --profile sdk`. This fixture +# retains the same two-tool behavior without defining a supported launch path. - id: sdk-jsonrpc-server name: '@deepseek-ai/dsh-sdk-jsonrpc-server' diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 9478bfcb2a..4dc1bb405a 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 5e44b3821d1272923f1545e697d91a434374d24a -README.zh.md: 1bf06b993c0379ecebf8246a50e1fc5fe5f4fce1 +README.md: 6d1add5baa7c73033dadc4247a03b546e6ff9dfd +README.zh.md: da2606b93cae1a6a642a6ee15ac5f0cfc8ce6457 diff --git a/packages/README.md b/packages/README.md index 5e44b3821d..6d1add5baa 100644 --- a/packages/README.md +++ b/packages/README.md @@ -50,7 +50,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`credentials/`](credentials/README.md) | Credential reference/record seam + env-over-`.env` provider + authorization flows | Product — stable API | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API | -| [`sdk/`](sdk/README.md) | Out-of-process SDK: JSON-RPC protocol, TypeScript client/server, and private Python carrier | Product — stable API | +| [`sdk/`](sdk/README.md) | Out-of-process SDK: JSON-RPC protocol and TypeScript client/server | Product — stable API | | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable API | | [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable API | | [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable API | diff --git a/packages/README.zh.md b/packages/README.zh.md index 1bf06b993c..da2606b93c 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -50,7 +50,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`credentials/`](credentials/README.zh.md) | 凭据引用/记录 seam + 环境变量优先于 `.env` 的提供方 + 授权 flow | 产品:稳定 API | | [`storage/`](storage/README.zh.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定 API | | [`workspace/`](workspace/README.zh.md) | Workspace 实体 | 产品:稳定 API | -| [`sdk/`](sdk/README.zh.md) | 进程外 SDK:JSON-RPC 协议、TypeScript 客户端/服务器和私有 Python 载体 | 产品:稳定 API | +| [`sdk/`](sdk/README.zh.md) | 进程外 SDK:JSON-RPC 协议与 TypeScript 客户端/服务器 | 产品:稳定 API | | [`acp/`](acp/README.zh.md) | 仅面向自动化的 ACP(Agent Client Protocol)服务器 | 产品:稳定 API | | [`interaction/`](interaction/README.zh.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 | 产品:稳定 API | | [`boot/`](boot/README.zh.md) | 共享的 app bin 启动粘合层 | 产品:稳定 API | diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index bcdd303a0d..177b740547 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 9965d6d57f4ec6cd9a93650bbd0096b059fb010b -README.zh.md: 436b19e4b2a05f4462f7636fccd3911b8ed41548 +README.md: 81c53abb799a91a2f7601f7506defbe9dc465820 +README.zh.md: 0d9e0707e0e809f4b2db0643a04619573107941a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 9965d6d57f..81c53abb79 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shared Loader boot glue for [`dsh`](../../../apps/cli/README.md) profiles and the [temporarily packaged Python SDK runtime](../../../python/README.md). The product launcher owns profile composition and process lifecycle; the direct-config helpers remain only for that held-back runtime until its later migration. +Shared Loader boot glue for [`dsh`](../../../apps/cli/README.md) profiles, including the CLI packaged by the [Python runtime wheel](../../../python/README.md). The product launcher owns profile composition and process lifecycle. Direct-config helpers serve lower-level embedders and tests; they do not define another supported application entrypoint. | Export | Role | |---|---| @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm managing in-box packages. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable writes real ESM proxy packages that mirror explicit exports and re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 436b19e4b2..0d9e0707e0 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -供 [`dsh`](../../../apps/cli/README.zh.md) profile 与[暂时打包的 Python SDK runtime](../../../python/README.zh.md) 共用的 Loader 启动粘合层。产品启动器负责 profile 组合与进程生命周期;直接配置 helper 只为暂缓迁移的 runtime 保留,直至后续迁移。 +供 [`dsh`](../../../apps/cli/README.zh.md) profile 共用的 Loader 启动粘合层,也用于 [Python 运行时 wheel](../../../python/README.zh.md)打包的 CLI。产品启动器负责 profile 组合与进程生命周期。直接配置 helper 服务于底层 embedder 与测试,不会定义另一个受支持的应用入口。 | 导出 | 职责 | |---|---| @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而无需由 pnpm 管理随安装内置的包。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则写入真实 ESM 代理包,镜像显式 exports 并重新导出虚拟模块 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/sdk/README.i18n.yaml b/packages/sdk/README.i18n.yaml index 05788b05a5..e46b9b6698 100644 --- a/packages/sdk/README.i18n.yaml +++ b/packages/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sdk/README.md -README.md: 663fa95dc56ecd71c7a2639b6edb45caacbab860 -README.zh.md: 0343ad477f9fa522666cd8d4440bfe645f309c2e +README.md: 7227e9c2c93b3f870d0affc74471d7801f37d4ef +README.zh.md: 3b6f5556cb539abafd1a1e8c866f09a44a2d2f1a diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 663fa95dc5..7227e9c2c9 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -2,11 +2,10 @@ English | [中文](README.zh.md) -This group contains the protocol stack for driving a Harness runtime from another process. The TypeScript client launches the matching `dsh` CLI with a named profile and ordered patches; the private Python carrier preserves the current packaged direct-config runtime until Python moves through the same profile path. The [TypeScript SDK decision](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client contract, and the [toolchain removal](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md) owns the product boundary. +This group contains the protocol stack for driving a Harness runtime from another process. The TypeScript and Python clients both launch `dsh` with a named profile and ordered patches; no package in this group defines a separate application. The [TypeScript SDK decision](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client contract, and the [Python profile-runtime decision](../../.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md) owns the packaged Python launch. | Package | Role | |---|---| | [`protocol/`](protocol/README.md) | Defines the SDK runtime wire protocol | | [`client/`](client/README.md) | Drives a Harness runtime through the TypeScript client API | | [`server/`](server/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC | -| [`python-runtime/`](python-runtime/README.md) | Private direct-config carrier for the temporarily unchanged Python SDK runtime | diff --git a/packages/sdk/README.zh.md b/packages/sdk/README.zh.md index 0343ad477f..3b6f5556cb 100644 --- a/packages/sdk/README.zh.md +++ b/packages/sdk/README.zh.md @@ -2,11 +2,10 @@ [English](README.md) | 中文 -本组包含用于从另一进程驱动 Harness 运行时的协议栈。TypeScript 客户端通过具名 profile 与有序 patch 启动匹配版本的 `dsh` CLI;私有 Python 载体在 Python 迁移到同一 profile 路径之前,保留当前打包后的直读配置运行时。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)负责客户端约定,[工具链移除](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)负责产品边界。 +本组包含用于从另一进程驱动 Harness 运行时的协议栈。TypeScript 与 Python 客户端都通过具名 profile 与有序 patch 启动 `dsh`;本组没有任何包定义独立应用。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)负责客户端约定,[Python profile 运行时决策](../../.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责打包后的 Python 启动。 | 包 | 职责 | |---|---| | [`protocol/`](protocol/README.zh.md) | 定义 SDK 运行时通信协议 | | [`client/`](client/README.zh.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 | | [`server/`](server/README.zh.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 | -| [`python-runtime/`](python-runtime/README.zh.md) | 为暂时保持不变的 Python SDK 运行时提供私有直读配置载体 | diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index adf84f76c4..5301b4b19e 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/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/sdk/server/README.md -README.md: e3deaa5288eb94d64a7f5a8425a829e3eed8896e -README.zh.md: a7273e9b4efbfae8e849205ea86b4c0c90588f7c +README.md: 547836ddab99d5c5a5c39c013c38b4bb25ba7e7d +README.zh.md: 97a7e9897fbf338a79c2cb33a8bab809c1cd3899 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index e3deaa5288..547836ddab 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkJsonRpcServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../protocol/README.md), shared with the client SDKs. The TypeScript client receives this server through `dsh --profile sdk`; the private [Python runtime carrier](../python-runtime/README.md) temporarily supplies a direct-config application around it. +The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkJsonRpcServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../protocol/README.md), shared with the client SDKs. TypeScript and Python clients receive this server through `dsh --profile sdk` or another profile that mounts the same row. ## Wiring -`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`. +`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding Loader composition. ## Config @@ -22,7 +22,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s ## Wire notes -`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`. +`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from the surrounding composition. ## Model Experience @@ -30,7 +30,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s #### What the model sees -For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the plugins in the surrounding `cordis.yml`. +For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. #### Token effect diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index a7273e9b4e..97a7e9897f 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.zh.md),与客户端 SDK 共享。TypeScript 客户端通过 `dsh --profile sdk` 获得该服务器;私有 [Python 运行时载体](../python-runtime/README.zh.md)暂时为其提供直读配置应用。 +`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.zh.md),与客户端 SDK 共享。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 或挂载同一配置项的其他 profile 获得该服务器。 ## 组装 -`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他能力由外围 `cordis.yml` 提供。 +`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他能力由外围 Loader 组合提供。 ## 配置 @@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 协议说明 -`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由外围组合提供。 ## 模型体验 @@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### 模型看到的内容 -对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自外围 `cordis.yml` 中的插件。 +对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 #### Token 影响 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 8c5a23e37b..fee9373a2d 100644 --- a/python/README.i18n.yaml +++ b/python/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 python/README.md -README.md: 75276a915eb4b63f84e0876de46e6d8d63540b59 -README.zh.md: f41822d66520869e5235a6fedd895b83a52639c9 +README.md: 7cb822e475acaa36a2a1637b0b4830e7dcabcb7a +README.zh.md: e571b936dad5d14db834fcabed363b5b7c199807 diff --git a/python/README.md b/python/README.md index 75276a915e..7cb822e475 100644 --- a/python/README.md +++ b/python/README.md @@ -9,11 +9,11 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com | Directory | Dist / module | Role | |---|---|---| | [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | -| [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled runtime binaries and default agent configuration | +| [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled `dsh` CLI executable and native sidecars | ## Behavior -The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client selects the channel and supplies default configuration; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete runtime-selection and configuration contracts. +The SDK starts the matching bundled `dsh --profile sdk` runtime unless the caller selects another `dsh` executable or profile. Every launch requires an explicitly selected Harness home; Python never silently reads `~/.dsh`. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own runtime selection, profiles, patches, and external plugin management. ## Contributor workflows diff --git a/python/README.zh.md b/python/README.zh.md index f41822d665..e571b936da 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -9,11 +9,11 @@ | 目录 | 分发名/模块 | 职责 | |---|---|---| | [sdk](sdk/README.zh.md) | `deepseek-harness-sdk` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | -| [sdk-runtime](sdk-runtime/README.zh.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | +| [sdk-runtime](sdk-runtime/README.zh.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置 `dsh` CLI 可执行程序与原生伴随文件 | ## 行为 -除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端选择通道并提供默认配置;运行时本身始终要求显式配置。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义完整的运行时选择与配置约定。 +除非调用方选择另一个 `dsh` 可执行程序或 profile,否则 SDK 会启动匹配的内置 `dsh --profile sdk` 运行时。每次启动都要求显式选择 Harness home;Python 绝不会静默读取 `~/.dsh`。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义运行时选择、profile、patch 与外部插件管理约定。 ## 贡献者工作流 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 75c657c153..bff1468133 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: e96be7af10e0008cc0fe5dea4ca51529f102fd7b -development.zh.md: e4ca1c980c36fdea381e9b8a6c615276e279b4d2 +development.md: 145f958a9df28e76456fb16a68b36fe318afa323 +development.zh.md: 557d6ba1f766dca61ab7bc73bf71ced2eb79afbe diff --git a/python/development.md b/python/development.md index e96be7af10..145f958a9d 100644 --- a/python/development.md +++ b/python/development.md @@ -31,7 +31,7 @@ That suite drives fake runtime peers. `scripts/smoke-python-runtime.py` drives t ```sh uv run --project python/sdk python scripts/smoke-python-runtime.py \ - --scenario sdk-minimal --exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 + --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the checked-in minimal composition's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job; it drops the dynamic runtime-context snapshot, which the same composition emits on macOS and not on Linux ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)). `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. @@ -43,18 +43,20 @@ An interactive smoke test needs `DEEPSEEK_API_KEY` in the environment or reposit ```python from deepseek_harness import DeepSeekHarness -with DeepSeekHarness() as harness: +with DeepSeekHarness(dsh_home="/absolute/path/to/test-dsh-home") as harness: print(harness.run("say hi").final_response) ``` +Alternatively export a non-empty `DSH_HOME`. The SDK rejects a launch that would silently use `~/.dsh`. + ## Run against Node source -Repository contributors can select either development carrier: +Repository contributors can select either development route; both execute the normal `dsh --profile sdk` launcher: - Set `DSH_RUNTIME_MODE=node` to use the built Node carrier on system Node `>=22.19`. The build script refreshes this carrier, but distributions never include or auto-select it. -- Set `launch_args_override=("./node_modules/.bin/tsx", "packages/sdk/python-runtime/src/packaged-bin.ts")` with the repository root as `cwd` to run the private carrier's unbuilt TypeScript source. Supply `cordis=...` when the default configuration is not suitable. +- Set `dsh_bin` to the absolute built `apps/cli/lib/bin.js` path to exercise the checkout's CLI directly. Supply an explicit `dsh_home`, plus `profile` and ordered `patches` as needed. -See `python/sdk/tests/manual_sdk_agent_smoke.py` for a complete source-mode invocation. +`python/sdk/tests/manual_sdk_agent_smoke.py` uses the internal `_launch_args` test adapter to exercise the unbuilt TypeScript CLI under tsx. Arbitrary argv replacement is intentionally absent from the public SDK. ## Build distributions @@ -71,7 +73,7 @@ print(release["pep440_version"](release["repository_version"]())) PY )" python scripts/build-python-release.py --package sdk --output-dir dist-python -python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 --output-dir dist-python pip install \ "dist-python/deepseek_harness_sdk-$version-py3-none-any.whl" \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" diff --git a/python/development.zh.md b/python/development.zh.md index e4ca1c980c..557d6ba1f7 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -31,7 +31,7 @@ uv run --project python/sdk pytest ```sh uv run --project python/sdk python scripts/smoke-python-runtime.py \ - --scenario sdk-minimal --exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 + --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` 其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定了签入的极简组合所组装的系统提示词、对外公布的工具 schema 以及模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败;它会丢弃动态运行时上下文快照——同一组合在 macOS 上会发出它,在 Linux 上不会([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 @@ -43,18 +43,20 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ ```python from deepseek_harness import DeepSeekHarness -with DeepSeekHarness() as harness: +with DeepSeekHarness(dsh_home="/absolute/path/to/test-dsh-home") as harness: print(harness.run("say hi").final_response) ``` +也可以导出非空 `DSH_HOME`。SDK 会拒绝可能静默使用 `~/.dsh` 的启动。 + ## 针对 Node 源码运行 -仓库贡献者可以选择以下任一开发载体: +仓库贡献者可以选择以下任一开发路径;两者都执行普通的 `dsh --profile sdk` 启动器: - 设置 `DSH_RUNTIME_MODE=node`,在系统 Node `>=22.19` 上使用已构建的 Node 载体。构建脚本会刷新该载体,但分发物绝不会包含或自动选择它。 -- 将仓库根目录设为 `cwd`,并设置 `launch_args_override=("./node_modules/.bin/tsx", "packages/sdk/python-runtime/src/packaged-bin.ts")`,以运行私有载体未构建的 TypeScript 源码。默认配置不合适时,请提供 `cordis=...`。 +- 将 `dsh_bin` 设置为已构建 `apps/cli/lib/bin.js` 的绝对路径,直接验证当前 checkout 的 CLI。请显式提供 `dsh_home`,并按需提供 `profile` 与有序 `patches`。 -完整的源码模式调用见 `python/sdk/tests/manual_sdk_agent_smoke.py`。 +`python/sdk/tests/manual_sdk_agent_smoke.py` 使用内部 `_launch_args` 测试适配器,通过 tsx 验证未构建的 TypeScript CLI。公开 SDK 刻意不提供任意 argv 替换。 ## 构建分发包 @@ -71,7 +73,7 @@ print(release["pep440_version"](release["repository_version"]())) PY )" python scripts/build-python-release.py --package sdk --output-dir dist-python -python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 --output-dir dist-python pip install \ "dist-python/deepseek_harness_sdk-$version-py3-none-any.whl" \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index c4ccb18bf1..ddcef99f9e 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 67d3842a9255250f66f22ff1f9422b26c3cf3282 -README.zh.md: 47c94b29d68eb915fae5303274fe2888274c1f82 +README.md: 52103b538be0ebc79572d8477e407f324e419ce6 +README.zh.md: e4fe7673a510102504454d5a6113ec7ec6ffd086 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 67d3842a92..52103b538b 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -1,31 +1,36 @@ -# DeepSeek Harness Runtime Wheel +# deepseek-harness-runtime-bin -English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.zh.md) +English | [中文](README.zh.md) -Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs. +Platform runtime wheel for the DeepSeek Harness Python SDK. It packages the normal `dsh` CLI and its closed Node dependency tree into a native executable, so SDK use requires no system Node.js. This package publishes wheels only. -## Runtime carriers +## Installed commands and artifacts -Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: +The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Python module. `dsh` forwards its arguments to the bundled executable and requires a non-empty `DSH_HOME`; it never falls back to `~/.dsh`. -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) with a target-native ripgrep `-rg` sidecar. macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. -- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. +Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory. Linux and macOS wheels include a target-native `-rg` sidecar; macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, and macOS arm64. The wheel tag and payload must match exactly. -Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the private `dsh-sdk-python-runtime-closure` deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. +Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. -The bundled plugin set includes `@deepseek-ai/dsh-mcp-client`, so an external Cordis config can connect to stdio or Streamable HTTP MCP servers and expose their tools to the model. The wheel does not bundle MCP server programs or credentials: a stdio config supplies its executable and arguments, while a Streamable HTTP config supplies its URL and headers. The bridge supports MCP tools; MCP Resources and Prompts remain unsupported. +Both carriers execute the same `dsh` grammar and profiles. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. -A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. +## Python module API -Each wheel contains exactly one runtime executable and its matching ripgrep `-rg` sidecar. The macOS wheel also contains its matching native spawn helper; any missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use filesystem-search or PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime executables, missing or extra sidecars, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. +- `bundled_package_dir() -> Path` returns the installed module-data root and verifies its release metadata. +- `bundled_runtime_path() -> Path` returns the current platform executable and verifies required sidecars. +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` returns the executable argv by default. Explicit `mode="node"` or `DSH_RUNTIME_MODE=node` selects the repo-only Node carrier. +- `main()` implements the installed `dsh` console command and rejects an absent or blank `DSH_HOME` before replacing the Python process. -## Resolution API +Unsupported platforms and missing executables or sidecars raise `FileNotFoundError` with the build and installation routes. Unknown runtime modes raise `ValueError`. -- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. -- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only); it validates the required sibling `-rg` sidecar on every platform and the `-spawn-helper` sidecar on macOS. The node carrier has no single-path equivalent and launches via the argv tuple above. -- `bundled_default_config_path() -> Path` — the checked-in default config (see below). -- `bundled_package_dir() -> Path` — the installed package data root. +## Packaged profile resolution -## Zero-config design +`dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. -The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving interface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-sdk-jsonrpc-server`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. +External profile management uses `dsh plugin --profile ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not. + +## Build and distribution + +From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. + +The installed-wheel smoke creates a clean virtual environment outside the checkout, proves distribution and executable provenance, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 47c94b29d6..e4fe7673a5 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -1,31 +1,36 @@ -# DeepSeek Harness 运行时 wheel 包 +# deepseek-harness-runtime-bin -[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) | 中文 +[English](README.md) | 中文 -Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 +DeepSeek Harness Python SDK 的平台运行时 wheel。它把普通 `dsh` CLI 及其封闭的 Node 依赖树打包成原生可执行程序,因此使用 SDK 不需要系统 Node.js。本包只发布 wheel。 -## 运行时载体 +## 安装命令与产物 -两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: +Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模块。`dsh` 将参数转发给内置可执行程序,并要求非空 `DSH_HOME`;它不会回退到 `~/.dsh`。 -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),以及匹配目标平台的 ripgrep `-rg` 伴随文件。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 -- **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 +生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序;macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64 与 macOS arm64。Wheel tag 必须与载荷严格匹配。 -两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的私有 `dsh-sdk-python-runtime-closure` 部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 +仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 与 sdist 均不包含它。 -内置插件集合包含 `@deepseek-ai/dsh-mcp-client`,因此外部 Cordis 配置可以连接 stdio 或 Streamable HTTP MCP server,并向模型提供这些 server 的工具。wheel 包不包含 MCP server 程序或凭据:stdio 配置需要提供可执行程序及其参数,Streamable HTTP 配置需要提供 URL 和请求头。该桥接仅支持 MCP 工具,尚不支持 MCP Resources 与 Prompts。 +两种载体执行相同的 `dsh` 语法和 profile。私有 `dsh-python-runtime-closure` manifest 定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 -exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 +## Python 模块 API -每个 wheel 包只包含一个运行时可执行文件及其匹配的 ripgrep `-rg` 伴随文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少任一伴随文件都意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用文件系统搜索或 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在或存在多个运行时可执行文件、缺失或多余的伴随文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 +- `bundled_package_dir() -> Path` 返回已安装模块数据根目录,并校验发布元数据。 +- `bundled_runtime_path() -> Path` 返回当前平台可执行程序,并校验必需伴随文件。 +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` 默认返回可执行程序 argv。显式 `mode="node"` 或 `DSH_RUNTIME_MODE=node` 会选择仅限仓库使用的 Node 载体。 +- `main()` 实现已安装的 `dsh` 控制台命令,并在替换 Python 进程前拒绝缺失或空白的 `DSH_HOME`。 -## 解析 API +不支持的平台以及缺失的可执行程序或伴随文件会抛出 `FileNotFoundError`,并指出构建与安装路径。未知运行时模式会抛出 `ValueError`。 -- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体);它会在所有平台校验必要的 `-rg` 伴随文件,并在 macOS 上额外校验 `-spawn-helper` 伴随文件。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 -- `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 -- `bundled_package_dir() -> Path`——已安装包的数据根目录。 +## 打包后的 profile 解析 -## 零配置设计 +`dsh` 在显式 home 下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理镜像显式运行时 exports、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与原生 addon 一同打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一强制语义是运行时设计的一部分,本包不会弱化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-sdk-jsonrpc-server`),缺了它,启动出的 agent(智能体)就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、agent 核心、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统提供方。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统提供方则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 +外部 profile 管理使用 `dsh plugin --profile ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。 + +## 构建与分发 + +在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 + +Installed-wheel smoke 会在 checkout 外创建干净虚拟环境,证明 distribution 与可执行程序来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。另见 [Python 贡献者工作流](../development.zh.md)与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 4b93a8d04e..7a8ef7c0fd 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: 99515c52e6314dc29788a324338699d75c0a4451 -README.zh.md: 6ec268545107478ce9f347cdfb7f17d4a8afd151 +README.md: edf5ced63f181bb17895d81cc060577916554305 +README.zh.md: e03a19e5b8f0cef2e2f9d86f8d91b8808e268ff4 diff --git a/python/sdk/README.md b/python/sdk/README.md index 99515c52e6..edf5ced63f 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -1,51 +1,64 @@ # DeepSeek Harness Python SDK -English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md) +English | [中文](README.zh.md) -Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The -runtime inherits normal DeepSeek Harness environment variables such as -`DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model -endpoints directly or point those variables at a local proxy. - -Install the `deepseek-harness-sdk` distribution from PyPI; the import module remains `deepseek_harness`: +Python subprocess SDK for driving DeepSeek Harness over newline-delimited JSON-RPC on stdio. Install `deepseek-harness-sdk`; it installs the exact same-version `deepseek-harness-runtime-bin` wheel for the current platform. ```sh python -m pip install deepseek-harness-sdk ``` -Installing `deepseek-harness-sdk` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: +## Start a runtime -```py -from deepseek_harness import DeepSeekHarness +The Python SDK has no separate application entrypoint. It launches the bundled `dsh` CLI with `--profile sdk`; the selected profile owns the JSON-RPC server, agent composition, credentials, persistence, tools, and shutdown behavior. -with DeepSeekHarness() as harness: - result = harness.run("Say hi.") -``` - -`DeepSeekHarness` keeps its lazily started runtime subprocess for reuse across calls. Use it as a context manager, as above, or call `close()` explicitly when finished. - -By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence with an explicitly composed semantic checkpoint policy, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-sdk-jsonrpc-server` entry in the config and pass the Cordis config path. +Every launch requires an explicit Harness home. Pass `dsh_home` or provide a non-empty `DSH_HOME` in the child environment. The SDK deliberately never discovers `~/.dsh`. ```py from deepseek_harness import DeepSeekHarness with DeepSeekHarness( - provider="deepseek-official", - model="deepseek-v4-flash", - max_tokens=49_152, - cordis="examples/python-sdk-agent/cordis.yml", + dsh_home="/absolute/path/to/isolated-dsh-home", + cwd="/absolute/path/to/workspace", +) as harness: + result = harness.run("Say hi.", session_id="example-001") + +print(result.final_response) +``` + +`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. + +## Customize plugins + +Persistent customization belongs to a `dsh` profile. Initialize the shipped SDK profile and install an external bundle with the runtime wheel's `dsh` command: + +```sh +export DSH_HOME=/absolute/path/to/isolated-dsh-home +dsh --profile sdk --dump-default-config >/dev/null +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +The `file:` form installs the local bundle into the profile package tree, where its peer imports reach the bundled installation fallback. The profile manifest records installed dependencies and ordered bundle layers; its `$DSH_HOME/profiles/sdk/cordis.patch.yml` is the persistent user patch. `dsh plugin` needs `pnpm` only when managing external packages. Running the SDK does not require system Node.js. + +For an invocation-specific change, pass one or more patch files. They become absolute and are forwarded in order after the profile and home patch layers: + +```py +with DeepSeekHarness( + dsh_home="/absolute/path/to/isolated-dsh-home", + profile="sdk", + patches=("/absolute/path/to/first.patch.yml", "/absolute/path/to/last.patch.yml"), ) as harness: result = harness.run("Make the requested code change.") ``` -`provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. +`profile` may select another existing profile, but that composition must retain `@deepseek-ai/dsh-sdk-app` or another `@deepseek-ai/dsh-sdk-jsonrpc-server` row. Misconfiguration fails during CLI boot or SDK initialization; there is no complete-config fallback. `dsh_bin` may select another `dsh` executable while preserving the same profile grammar. Arbitrary argv replacement remains an internal fake-runtime test adapter, not public API. -The [Python SDK tutorial](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md) provides an ordered installation and first-run path without the Web UI. The [`python-sdk-agent` example](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/python-sdk-agent/README.md) owns the complete standalone Cordis file used there. +## Results and notifications -`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end` in the interval, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the runtime protocol and raises `SdkProtocolError`. Both result fields describe the owned interval rather than an output or ending causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. +`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. -`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. +`HarnessClient` retains discovered subagent ancestry for the runtime process lifetime. During `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and known descendants in wire order. `RunResult.events` contains root-session events only, so descendant output cannot replace the root response. The low-level `session_prompt()` returns the queued message id immediately; callers that bypass `Session.run()` own the later activity boundary. -The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. +The selected home stores profiles, plugins, credentials, settings, and sessions. Use a fresh home when those resources must be isolated, and a fresh session id for independent work. Reusing both a harness and session id continues the durable conversation and session-owned resources. -`cwd` and `runtime_cwd` are resolved to absolute paths before subprocess launch, environment injection, and the wire handshake. The public API exposes only applied options: deployment persona and persistence belong in `cordis.yml`, while `session_root` remains the high-level convenience that sets `DSH_SESSION_ROOT`. +See the [Python tutorial](../../docs/user/guide/python-sdk.md), [`python-sdk-agent` example](../../examples/python-sdk-agent/README.md), and [runtime wheel reference](../sdk-runtime/README.md). diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 6ec2685451..e03a19e5b8 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -1,48 +1,64 @@ # DeepSeek Harness Python SDK -[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md) | 中文 +[English](README.md) | 中文 -通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 - -请从 PyPI 安装 `deepseek-harness-sdk` 分发包;导入模块仍为 `deepseek_harness`: +用于通过 stdio 上按行分隔的 JSON-RPC 驱动 DeepSeek Harness 的 Python 子进程 SDK。安装 `deepseek-harness-sdk` 时,会同时安装当前平台上版本完全相同的 `deepseek-harness-runtime-bin` wheel。 ```sh python -m pip install deepseek-harness-sdk ``` -安装 `deepseek-harness-sdk` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: +## 启动运行时 -```py -from deepseek_harness import DeepSeekHarness +Python SDK 没有独立的应用入口。它以 `--profile sdk` 启动内置的 `dsh` CLI;所选 profile 负责 JSON-RPC 服务器、agent 组合、凭据、持久化、工具和关闭流程。 -with DeepSeekHarness() as harness: - result = harness.run("Say hi.") -``` - -`DeepSeekHarness` 会保留其按需启动的运行时子进程,以便在多次调用之间复用。请像上例一样将其用作上下文管理器,或在使用完毕后显式调用 `close()`。 - -默认情况下,SDK 会启动 `deepseek-harness-runtime-bin` 包内置的单文件可执行程序 `dsh-jsonrpc-agent`,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置,其中包括 stdio JSON-RPC 服务器、agent core(智能体核心)、预载的 DeepSeek 适配器、采用显式组合语义检查点策略的 JSONL 会话持久化,以及本地 bash。要运行自己的插件组合,请在配置中保留 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项,并传入 Cordis 配置文件路径。 +每次启动都必须显式指定 Harness home。请传入 `dsh_home`,或在子进程环境中提供非空的 `DSH_HOME`。SDK 刻意不会发现 `~/.dsh`。 ```py from deepseek_harness import DeepSeekHarness with DeepSeekHarness( - provider="deepseek-official", - model="deepseek-v4-flash", - max_tokens=49_152, - cordis="examples/python-sdk-agent/cordis.yml", + dsh_home="/absolute/path/to/isolated-dsh-home", + cwd="/absolute/path/to/workspace", +) as harness: + result = harness.run("Say hi.", session_id="example-001") + +print(result.final_response) +``` + +`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 + +## 自定义插件 + +持久自定义属于 `dsh` profile。使用运行时 wheel 提供的 `dsh` 命令初始化随附的 SDK profile,并安装外部 bundle: + +```sh +export DSH_HOME=/absolute/path/to/isolated-dsh-home +dsh --profile sdk --dump-default-config >/dev/null +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +`file:` 形式会把本地 bundle 安装到 profile 包树中,使其 peer import 可以到达内置安装后备。Profile manifest 会记录已安装依赖与有序 bundle 层;`$DSH_HOME/profiles/sdk/cordis.patch.yml` 是持久用户 patch。只有管理外部包时,`dsh plugin` 才需要 `pnpm`。运行 SDK 不需要系统 Node.js。 + +对于单次调用的变更,可传入一个或多个 patch 文件。它们会转成绝对路径,并在 profile 层与 home patch 层之后按顺序传给 CLI: + +```py +with DeepSeekHarness( + dsh_home="/absolute/path/to/isolated-dsh-home", + profile="sdk", + patches=("/absolute/path/to/first.patch.yml", "/absolute/path/to/last.patch.yml"), ) as harness: result = harness.run("Make the requested code change.") ``` -`provider` 选择指定 Cordis 组合所注册的提供方路由;`model` 是该适配器解析出的模型 ID。`max_tokens` 是一个可选的正整数,用于限制根 agent 及其进程内后代在每次请求中输出的 token 数量;省略该参数时,由提供方的默认行为决定输出上限。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方专属的凭据和端点,并选择 pi-ai 已安装 catalog 中存在的任意提供方/模型组合。 +`profile` 可以选择另一个已存在的 profile,但该组合必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项。配置错误会在 CLI 启动或 SDK 初始化时失败;不存在完整配置回退。`dsh_bin` 可以选择另一个 `dsh` 可执行程序,同时保持相同的 profile 语法。任意 argv 替换仅是内部 fake-runtime 测试适配器,不属于公开 API。 -[Python SDK 教程](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md)提供一套无需使用 Web UI、按步骤完成安装和首次运行的流程。该教程所用的完整独立 Cordis 配置文件位于 [`python-sdk-agent` 示例](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/python-sdk-agent/README.md)中。 +## 结果与通知 -`Session.run()` 的活动区间从其提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是该区间内根会话最后一个 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反运行时协议,并会抛出 `SdkProtocolError`。这两个结果字段描述的是 `Session.run()` 所界定的活动区间,并不表示某项输出或结束原因在因果上归属于该提示词。steering(中途引导)、注入的上下文和其他排队工作,也可能在 agent 进入空闲状态前参与这段活动。 +`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入 idle 时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的 assistant 文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 -`HarnessClient` 会在运行时进程的整个生命周期内保留已发现的 subagent 谱系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 +`HarnessClient` 会在运行时进程的整个生命周期内保留已发现的子 agent 祖先关系。在 `Session.run()` 期间,`RunResult.notifications` 与 `on_notification` 按协议顺序接收根会话和已知后代的通知。`RunResult.events` 只包含根会话事件,因此后代输出不会替换根响应。底层 `session_prompt()` 会立即返回已排队消息的 id;绕过 `Session.run()` 的调用方自行负责后续活动边界。 -也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程指定配置。注入逻辑位于 `HarnessClient.start()`,因此底层客户端按默认方式启动时也具有该行为:如果启动方式最终解析为内置运行时,且既没有设置 `cordis`,也没有设置非空的 `DSH_CORDIS_CONFIG`(运行时将空值视为未设置,注入检查也是如此),系统就会使用内置默认配置;显式指定 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 时,则会完全禁用该注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md)。 +所选 home 保存 profile、插件、凭据、设置和会话。需要隔离这些资源时应使用新的 home;独立工作应使用新的 session id。同时复用 harness 与 session id 会延续持久对话和会话资源。 -`cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露由 SDK 直接应用的选项:部署 persona 和持久化配置应在 `cordis.yml` 中定义;`session_root` 则保留为设置 `DSH_SESSION_ROOT` 的高层便捷参数。 +另见 [Python 教程](../../docs/user/guide/python-sdk.zh.md)、[`python-sdk-agent` 示例](../../examples/python-sdk-agent/README.zh.md)和[运行时 wheel 参考](../sdk-runtime/README.zh.md)。 From 9edf1b9f108e4e78f6bf2db7356e4318308afe37 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:04:19 +0800 Subject: [PATCH 064/138] fix(python-sdk): harden profile runtime startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve packaged profile proxies with Node ESM import conditions from each package installation, and fail loud when an explicit runtime export or legacy main entry is missing. Serialize the shared profile fallback under the existing cross-process writer lock so concurrent dsh processes cannot observe partial proxies; either carrier now replaces the other carrier’s managed entry without manual cleanup. Give Python initialize its own 10-second default bound and name the selected profile in timeout diagnostics, while leaving ordinary agent turns unbounded by default. Package the dynamically resolved web frontend and skill-badge assets so the runtime wheel’s normal dsh profiles do not depend on pkg static-discovery accidents. Rewrite the root launch rule and every active stale SDK-runtime note to the shipped dsh profile architecture in both languages. Focused tests prove import-only and transitive package exports, lock contention, cross-carrier transitions, missing-entry failures, asset inventory, and bounded initialization. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 10 +- ...-executable-sdk-runtime-distribution.zh.md | 10 +- ...-single-dsh-application-launcher.i18n.yaml | 4 +- ...6-08-22-single-dsh-application-launcher.md | 18 +- ...8-22-single-dsh-application-launcher.zh.md | 18 +- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 6 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 6 +- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 4 +- ...typescript-sdk-and-sdk-subagent-backend.md | 2 +- ...escript-sdk-and-sdk-subagent-backend.zh.md | 2 +- AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 1 + apps/cli/src/bin.ts | 2 +- apps/cli/src/dump-config.ts | 5 +- apps/cli/src/profile-boot.ts | 12 +- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/package.json | 2 + packages/boot/app-boot/src/profile.ts | 92 ++++++---- packages/boot/app-boot/tests/profile.spec.ts | 164 +++++++++++++++--- packages/boot/app-boot/tsconfig.json | 3 + pnpm-lock.yaml | 6 + python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- python/sdk/src/deepseek_harness/api.py | 2 + python/sdk/src/deepseek_harness/client.py | 11 +- python/sdk/tests/test_client.py | 6 +- .../build-exe-for-python-sdk-assets.spec.ts | 27 +++ scripts/build-exe-for-python-sdk.ts | 5 + 35 files changed, 321 insertions(+), 127 deletions(-) create mode 100644 scripts/build-exe-for-python-sdk-assets.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 29b7caeb50..64c9153ca6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 50e63bbc4f664f9450f01acf45888991a27517d2 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cf92f42f508f005c64f09aa0127b28ed162cede5 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: e46dbbc119e2078e44632d81b333c8be5ab9d6d7 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7a1e3f445ea1c1f03df2b349a4391534a5502c3 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 50e63bbc4f..e46dbbc119 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,27 +36,27 @@ The Python client supplies an explicit Harness home and selects the `sdk` profil Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-sdk-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-sdk-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all three targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe with its required `-rg` sidecar and optional macOS helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. -The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-sdk-jsonrpc-server` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. +The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. ### Naming lineage -`@deepseek-ai/dsh-sdk-python-runtime` (the private carrier) → `dsh-sdk-python-runtime-closure` (the deploy manifest; no scope prefix, so it is not a dsh release package) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. +`dsh-python-runtime-closure` is the private deploy manifest and `deepseek-harness-sdk-runtime--` is the executable family. The wire `serverInfo.name` is `deepseek-harness-sdk-runtime`; the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules are `deepseek_harness` / `deepseek_harness_runtime`. ## Disposition of worker-style plugins diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index cf92f42f50..d7a1e3f445 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,27 +36,27 @@ Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 pa exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-sdk-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-sdk-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部三个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 -exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 +Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 ### 命名血统 -`@deepseek-ai/dsh-sdk-python-runtime`(私有载体)→ `dsh-sdk-python-runtime-closure`(部署 manifest;没有作用域前缀,因此不属于 dsh 发布包)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 +`dsh-python-runtime-closure` 是私有部署 manifest,`deepseek-harness-sdk-runtime--` 是可执行文件族。协议字段 `serverInfo.name` 是 `deepseek-harness-sdk-runtime`;Python 分发包名是 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名是 `deepseek_harness` / `deepseek_harness_runtime`。 ## 工作线程插件 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 1e9ba16226..de2009b1ce 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 69188e806d9192d230d1f1b52daf27a3b30481be -2026-08-22-single-dsh-application-launcher.zh.md: a6bb1909b019ea0d386bd2f4a1bb8f5199ef1974 +2026-08-22-single-dsh-application-launcher.md: 27ec766dc62a203a3054ec67828b8897ab5ad245 +2026-08-22-single-dsh-application-launcher.zh.md: 5fb7fb4234b093a6f69446e0d9b1d7fbb7f46cf3 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 69188e806d..27ec766dc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -8,7 +8,7 @@ English | [中文](2026-08-22-single-dsh-application-launcher.zh.md) DeepSeek Harness application processes need one owner for composition, plugin resolution, environment discovery, shutdown, and user customization. A dedicated app bin with a complete `cordis.yml` creates a second lifecycle beside profile launch: plugins installed into a profile do not reach it, behavior drifts from `dsh-base`, and SDK callers learn arbitrary process argv instead of the product's composition model. -The Python SDK distributes a native executable and three platform wheels whose embedded direct-config runtime cannot change launch architecture without rebuilding and validating the complete VFS closure. That distribution needs an explicit temporary exception, not a second general Node application pattern. +The Python SDK distributes a native executable and three platform wheels. Its packaged process must use the same profile launcher while preserving the closed VFS dependency tree, native sidecars, and installed-wheel evidence. ## Decision @@ -43,19 +43,15 @@ SDK users customize plugins through profiles. `dsh plugin --profile ...` Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inherited `DSH_HOME`, then `~/.dsh`. `subagent-dsh-sdk` instead requires an explicit absolute home, so a nested runtime cannot discover a person's profiles, installed plugins, credentials, or sessions through the operating-system home. DSH-specific ACP child examples also pass an isolated home; the ACP backend itself remains generic for non-DSH agents. -### Python exception and names +### Python runtime -The Python SDK's direct-config application lives in the private `packages/sdk/python-runtime` package named `@deepseek-ai/dsh-sdk-python-runtime`. Its only packaged executable entry is `lib/packaged-bin.js`, consumed by the private `dsh-sdk-python-runtime-closure` deploy root. It has no public npm bin. The runnable direct Python example is `examples/python-sdk-agent`. +The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk`, ordered patch files, and an explicit Harness home; the installed `dsh` console command exposes the same profile grammar. The runnable Python example is `examples/python-sdk-agent`. -Python-observable behavior remains fixed: Python API, SDK wire, default `cordis.yml`, environment variables, wheel distribution names, packaged executable names, sidecar names, explicit runtime options, zero-config behavior, and supported platforms. The stable SDK family remains `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, `@deepseek-ai/dsh-sdk-jsonrpc-server`, and wire identity `deepseek-harness-sdk-runtime`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. +The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. ### Enforcement -`verify-application-entrypoints` scans application/package manifests, executable sources, and root demo scripts. The allowlist classifies the `dsh` product bin, vendor-excluded scope, the private WebWorker build tool, test support, and the private Python carrier. An unclassified shebang, a new package bin, or a demo wrapper that bypasses `apps/cli/src/bin.ts` fails hygiene and the primary/static CI aggregates. - -## Deferred Python migration - -The Python runtime follow-up must move the packaged process through `dsh --profile sdk`, preserve the wheel's closed dependency and native sidecar behavior, and delete `@deepseek-ai/dsh-sdk-python-runtime`. Only after those conditions pass on Linux x64, Linux arm64, and macOS arm64 does the executable family change from `dsh-jsonrpc-agent-pkg--` to `deepseek-harness-sdk-runtime--`. The temporary carrier and current artifact names make that obligation visible without weakening current Python compatibility. +`verify-application-entrypoints` scans application/package manifests, executable sources, and root demo scripts. The allowlist classifies the `dsh` product bin, vendor-excluded scope, the private WebWorker build tool, and test support. An unclassified shebang, a new package bin, or a demo wrapper that bypasses `apps/cli/src/bin.ts` fails hygiene and the primary/static CI aggregates. ## Existing decisions and supersession @@ -88,7 +84,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o - Focused unit suites cover profile launch resolution, initialization bounds, SDK retries, server readiness, and nested isolated homes with 100% coverage on the changed runtime sources. - Keyless ACP and SDK snapshots boot real `dsh` profiles and pin protocol output plus persisted logs; the nested SDK composition boots a second real profile runtime. - The real-API workflow caps file parallelism at four because one profile e2e file can own several complete `dsh` subprocess trees; workflow tests pin that resource bound. -- The Python suite exercises exe and node carriers; all packaged-runtime scenarios, native macOS executable construction, both wheels, and clean-wheel default/MCP smokes retain the existing artifact names. +- The Python suite exercises exe and node carriers; packaged-runtime scenarios, native macOS executable construction, both wheels, and clean-wheel default/MCP smokes pin the `deepseek-harness-sdk-runtime-*` artifacts and profile launch. - `verify-application-entrypoints` includes invalid fixtures for package bins, executable sources, package-launching demo wrappers, and unclassified demos. ## Consequences @@ -98,4 +94,4 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o - SDK and ACP share the complete base application and one set of policy and tools; snapshots present intentional assembled differences explicitly. - Adding `@deepseek-ai/dsh` increases the TypeScript client's install size in exchange for a deterministic same-version runtime. - Trusted user patches can add a plugin that writes to stdout and corrupt their own protocol stream; shipped profiles guarantee purity, not arbitrary third-party composition. -- Python keeps a visibly private, narrowly allowed direct-config carrier until its platform artifact migration is independently proven. +- Python packages the ordinary `dsh` profile launcher while retaining a closed native runtime and no system-Node requirement for wheel users. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index a6bb1909b0..5fb7fb4234 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -8,7 +8,7 @@ Status: implemented DeepSeek Harness 应用进程需要由同一个机制负责组合、插件解析、环境发现、关闭和用户自定义。带完整 `cordis.yml` 的专用应用 bin 会在 profile 启动之外形成第二套生命周期:安装到 profile 的插件无法到达它,行为会与 `dsh-base` 偏离,SDK 调用方还需要学习任意进程 argv,而不是产品的组合模型。 -Python SDK 分发一个原生可执行文件和三个平台 wheel 包;其中嵌入的直读配置运行时只有在重建并验证完整 VFS 闭包后才能改变启动架构。该分发需要一个明确的临时例外,而不是另一种通用 Node 应用模式。 +Python SDK 分发一个原生可执行文件和三个平台 wheel 包。其打包进程必须使用同一 profile 启动器,同时保留封闭的 VFS 依赖树、原生伴随文件与 installed-wheel 证据。 ## Decision @@ -43,19 +43,15 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 直接使用 SDK 时遵循普通 Harness home 解析:显式 `dshHome`、继承的 `DSH_HOME`,最后是 `~/.dsh`。`subagent-dsh-sdk` 则要求显式绝对 home,因此嵌套运行时不会通过操作系统 home 发现个人 profile、已安装插件、凭据或会话。DSH 专用 ACP 子进程示例同样传入隔离 home;ACP 后端自身继续适用于非 DSH agent。 -### Python 例外与命名 +### Python 运行时 -Python SDK 的直读配置应用位于私有 `packages/sdk/python-runtime` 包,名称是 `@deepseek-ai/dsh-sdk-python-runtime`。它唯一的打包可执行入口是 `lib/packaged-bin.js`,由私有 `dsh-sdk-python-runtime-closure` 部署根消费。它没有公开 npm bin。可运行的直启 Python 示例是 `examples/python-sdk-agent`。 +Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端选择 `dsh --profile sdk`、有序 patch 文件和显式 Harness home;安装的 `dsh` 控制台命令暴露相同 profile 语法。可运行的 Python 示例是 `examples/python-sdk-agent`。 -Python 可观察行为保持不变:Python API、SDK 协议格式、默认 `cordis.yml`、环境变量、wheel 包分发名称、打包可执行文件名称、伴随文件名称、显式运行时选项、零配置行为与支持平台。稳定 SDK 包族继续是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol`、`@deepseek-ai/dsh-sdk-jsonrpc-server`,协议 identity 继续是 `deepseek-harness-sdk-runtime`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 +可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 ### 强制校验 -`verify-application-entrypoints` 扫描应用/包 manifest、可执行源码和根 demo 脚本。允许清单对 `dsh` 产品 bin、排除的 vendor 范围、私有 WebWorker 构建工具、测试支持以及私有 Python 载体进行分类。未分类的 shebang、新包 bin 或绕过 `apps/cli/src/bin.ts` 的 demo wrapper 都会使 hygiene 与 primary/static CI 聚合失败。 - -## 暂缓的 Python 迁移 - -Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk`,保持 wheel 包的封闭依赖与原生伴随文件行为,并删除 `@deepseek-ai/dsh-sdk-python-runtime`。只有这些条件在 Linux x64、Linux arm64 与 macOS arm64 全部通过后,可执行文件族才会从 `dsh-jsonrpc-agent-pkg--` 改名为 `deepseek-harness-sdk-runtime--`。临时载体与当前产物名称使这项义务清晰可见,同时不削弱当前 Python 兼容性。 +`verify-application-entrypoints` 扫描应用/包 manifest、可执行源码和根 demo 脚本。允许清单对 `dsh` 产品 bin、排除的 vendor 范围、私有 WebWorker 构建工具和测试支持进行分类。未分类的 shebang、新包 bin 或绕过 `apps/cli/src/bin.ts` 的 demo wrapper 都会使 hygiene 与 primary/static CI 聚合失败。 ## 既有决策与取代关系 @@ -88,7 +84,7 @@ Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk` - 聚焦单元套件覆盖 profile 启动解析、初始化时限、SDK 重试、服务器就绪和嵌套隔离 home,并对变更后的运行时源码实现 100% 覆盖率。 - 免密钥 ACP 与 SDK 快照启动真实 `dsh` profile,并钉住协议输出与持久化日志;嵌套 SDK 组合会启动第二个真实 profile 运行时。 - 真实 API 工作流把文件并行度限制为 4,因为一个 profile e2e 文件可能拥有多个完整 `dsh` 子进程树;工作流测试会钉住该资源上限。 -- Python 套件同时测试 exe 与 node 载体;全部打包运行时场景、原生 macOS 可执行文件构建、两个 wheel 包以及干净 wheel 默认/MCP 冒烟测试都保留既有产物名称。 +- Python 套件同时测试 exe 与 node 载体;打包运行时场景、原生 macOS 可执行文件构建、两个 wheel 包以及干净 wheel 默认/MCP 冒烟测试会钉住 `deepseek-harness-sdk-runtime-*` 产物与 profile 启动。 - `verify-application-entrypoints` 包含包 bin、可执行源码、直启包的 demo wrapper 与未分类 demo 等非法 fixture(测试前置数据)。 ## 影响 @@ -98,4 +94,4 @@ Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk` - SDK 与 ACP 共享完整 base 应用和同一份策略与工具;快照以显式差异呈现刻意采用的组装变化。 - 增加 `@deepseek-ai/dsh` 会扩大 TypeScript 客户端的安装体积,换来确定的同版本运行时。 - 受信任用户 patch 可以增加写入 stdout 的插件并破坏自己的协议流;随附 profile 保证纯净,不为任意第三方组合提供保证。 -- Python 保留一个清晰可见的私有直读配置载体,直到其平台产物迁移得到独立证明。 +- Python 打包普通 `dsh` profile 启动器,同时保留封闭原生运行时,wheel 用户无需系统 Node。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 2e98550ed0..17e40be1db 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 400915155460598e754c2f81d60b26e693ff438a -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: a3c9074817d1c1e9b3f59fbe5fb02910d20e02f4 +2026-08-23-python-sdk-dsh-profile-runtime.md: 14c58c85923aac1b54362c72f61f7f1e1c145124 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 9f584ca9bfcc7907caede70ef7e9781f2bc37aab diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 4009151554..14c58c8592 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -16,13 +16,13 @@ A normal profile cannot be adopted only at the Python wrapper. The runtime execu The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path. -The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, and timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path. +The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, a bounded initialization timeout, and optional turn/shutdown timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path. Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME` in the child environment. The SDK never discovers `~/.dsh`. The selected home consistently owns profiles, external plugins, credentials, settings, and sessions. ### Plugin customization -Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback. +Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. @@ -30,7 +30,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. -Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy mirrors the source package's explicit runtime exports and identity and re-exports its virtual module URLs. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. +Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit runtime exports under ESM import conditions and re-exports its virtual module URLs. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index a3c9074817..9f584ca9bf 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -16,13 +16,13 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。 -公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择与 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。 +公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择、有界初始化 timeout,以及可选的轮次/关闭 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。 每次 Python 启动都要求显式 `dsh_home`,或子进程环境中的非空 `DSH_HOME`。SDK 绝不会发现 `~/.dsh`。所选 home 统一拥有 profile、外部插件、凭据、设置与会话。 ### 插件自定义 -持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退。 +持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 @@ -30,7 +30,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 -普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理镜像源包的显式运行时 exports 与身份,并重新导出其虚拟模块 URL。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 +普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理按 ESM import 条件解析源包的显式运行时 exports,并重新导出其虚拟模块 URL。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index 1d9c28595a..06e2503aa4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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-27-typescript-sdk-and-sdk-subagent-backend.md -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: eda9d3a3de91944a298070d6cc22f632294f7a28 -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 1a72c6b1cdb7453b8468d6e6be37aec76323f714 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 37c341e964b556c7ab5fdd9081416883066b97d1 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: e97e951028de3bcda9fe11be0351072481c72dd9 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index eda9d3a3de..37c341e964 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -17,7 +17,7 @@ Three packages, layered exactly like the existing Python stack, plus one Service - **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, `provider`/`model` feeds the child's `initialize`, and `env` supplies explicit child-only values such as its API key. - **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. -`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); the private `@deepseek-ai/dsh-sdk-python-runtime` carrier consumes the shared protocol through its packaged closure. +`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical). TypeScript and Python clients both consume the shared protocol through `dsh --profile sdk`; the Python wheel packages that CLI and its closed dependency tree. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index 1a72c6b1cd..e97e951028 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -17,7 +17,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[ - **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`/profile/patch/home 配置选择隔离的 SDK 应用,`provider`/`model` 写入子进程 `initialize`,`env` 则提供子进程专用的显式值,例如其 API key。 - **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 -`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);私有 `@deepseek-ai/dsh-sdk-python-runtime` 载体通过其打包闭包消费共享协议。 +`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致)。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 消费共享协议;Python wheel 会打包该 CLI 及其封闭依赖树。 ## 测试 diff --git a/AGENTS.md b/AGENTS.md index 1fd35bb6c9..fa9515604b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ DeepSeek Harness is an all-plugin agent harness on vendored Cordis. Read [docs/a ## Application launch -Node apps launch only through `dsh` profiles; application-package bins, demos, and SDK argv escape hatches are forbidden. The private Python runtime is the sole temporary exception. [Architecture](docs/architecture.md#application-launch) owns scope and deferred artifact rename; `pnpm run verify-application-entrypoints` enforces it. +Supported Node applications launch only through `dsh` profiles; application-package bins, demos, and public SDK argv escape hatches are forbidden. [Architecture](docs/architecture.md#application-launch) owns the launch set; `pnpm run verify-application-entrypoints` enforces it. ## Repository layout diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f2637fe699..347f3b035e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,6 +66,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | +| [`import-meta-resolve`](https://github.com/wooorm/import-meta-resolve) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 321849f2d9..9e7c600c8d 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -41,7 +41,7 @@ switch (invocation.mode) { } case 'dump-config': { const { runDumpConfig } = await import('./dump-config.ts') - runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) + await runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) break } default: diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 1754eb4efd..dc2089e7c8 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -26,9 +26,10 @@ const NAME = 'dsh' * (the recovery diagnostic for a broken `cordis.patch.yml`, which is then * never parsed). * @param patches - `--patch` overlay paths, in argv order. + * @returns settlement after the profile is healed and the dump is written. */ -export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void { - const loaded = prepareProfile(profile, !defaultOnly) +export async function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): Promise { + const loaded = await prepareProfile(profile, !defaultOnly) const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({ label: layer.packageName, patches: layer.patches, diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 68c66a8e34..1c77b1ff8a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -115,8 +115,8 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump). * @returns the loaded profile. */ -export function prepareProfile(name: string, userLayer = true): Profile { - healProfilesModuleFallback(INSTALL_ANCHOR) +export async function prepareProfile(name: string, userLayer = true): Promise { + await healProfilesModuleFallback(INSTALL_ANCHOR) const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer }) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG) return profile @@ -154,11 +154,11 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { * @param patchFiles - `--patch` overlay paths, in argv order. * @returns the profile and its patch layers. */ -function composeProfile( +async function composeProfile( name: string, patchFiles: readonly string[], -): ComposedProfile { - const profile = prepareProfile(name) +): Promise { + const profile = await prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) @@ -207,7 +207,7 @@ function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown * @returns the settled root context and the shutdown controller. */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { - const composed = composeProfile(options.profile, options.patchFiles) + const composed = await composeProfile(options.profile, options.patchFiles) const app: { current?: Context } = {} const appReady = createAppReady() const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1dc66b2f08..a4e048b5fd 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -107,7 +107,7 @@ async function bootWeb( // upward walk. The flat fallback the preset boot maintains is what makes // them resolvable — the same mechanism, not a test-only shim. const home = dirname(settingsFile) - healProfilesModuleFallback(INSTALL_ANCHOR, home) + await healProfilesModuleFallback(INSTALL_ANCHOR, home) const profileDir = join(home, 'profiles', 'spec') await mkdir(profileDir, { recursive: true }) // Product Bundles are installed into the Profile, not the dsh app. Model diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 51ae01105f..ec30bc1e0c 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -519,7 +519,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/profiles. - healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome) + await healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome) const profileDir = join(harnessHome, 'profiles', 'scaffold') await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 177b740547..6bd721e291 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 81c53abb799a91a2f7601f7506defbe9dc465820 -README.zh.md: 0d9e0707e0e809f4b2db0643a04619573107941a +README.md: f48abfa445e4ac604b5e1bc694f8bae395b566e7 +README.zh.md: e903c74fb12fee0fca1d201d217dd28be8b7e08d diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 81c53abb79..f48abfa445 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable writes real ESM proxy packages that mirror explicit exports and re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves exports with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 0d9e0707e0..e903c74fb1 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则写入真实 ESM 代理包,镜像显式 exports 并重新导出虚拟模块 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则按 Node ESM import 条件解析 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 0b4b1d7a74..96594a9117 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -32,6 +32,8 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "import-meta-resolve": "^4.2.0", "js-yaml": "^4.2.0" }, "peerDependencies": { diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index e886f759b2..3f2a3d11c0 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -27,10 +27,12 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { resolve as resolveImport } from 'import-meta-resolve' import { loadOverlayPatches } from './index.ts' /** Directory under the Harness home holding every profile. */ @@ -205,7 +207,16 @@ export function initProfile( if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE) } -/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */ +function readModuleProxyRecord(link: string): ModuleProxyRecord | undefined { + try { + return JSON.parse(readFileSync(join(link, 'package.json'), 'utf8')) as ModuleProxyRecord + } catch { + // Missing or invalid metadata is not managed state; callers reject it. + return undefined + } +} + +/** Ensure `link` is a symlink to `target`, replacing a wrong link or a dsh-managed packaged proxy. */ function ensureSymlink(link: string, target: string): void { let stat try { @@ -217,12 +228,19 @@ function ensureSymlink(link: string, target: string): void { } if (stat !== undefined) { if (!stat.isSymbolicLink()) { - throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) + const existing = stat.isDirectory() ? readModuleProxyRecord(link) : undefined + if (existing?.dsh?.moduleFallback?.targets === undefined) { + throw new Error(`dsh: ${link} exists and is not a symlink or dsh-managed module proxy; remove it so dsh can manage the installation fallback`) + } + rmSync(link, { recursive: true }) + stat = undefined + } + if (stat !== undefined) { + if (readlinkSync(link) === target) return + // unlink deletes the reparse point itself on Windows too; rmSync treats a + // junction as a directory and throws EISDIR unless recursive. + unlinkSync(link) } - if (readlinkSync(link) === target) return - // unlink deletes the reparse point itself on Windows too; rmSync treats a - // junction as a directory and throws EISDIR unless recursive. - unlinkSync(link) } try { symlinkSync(target, link, 'junction') @@ -258,29 +276,40 @@ function isPackagedExecutable(): boolean { return (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined } -/** Resolve one package specifier from the dsh installation. */ -function packageEntryFromAnchor(anchor: string, specifier: string): string | undefined { +/** Resolve one package export with the ESM conditions used by the generated proxy. */ +function packageEntryFromPackage(packageName: string, packageDir: string, specifier: string): string { try { - return createRequire(anchor).resolve(specifier) - } catch { - return undefined + const resolved = resolveImport(specifier, pathToFileURL(join(packageDir, 'package.json')).href) + if (!resolved.startsWith('file:') || !existsSync(fileURLToPath(resolved))) { + throw new Error(`resolved to missing or non-file URL ${resolved}`) + } + return resolved + } catch (error) { + throw new Error(`dsh: cannot resolve ESM export ${specifier} from installed package ${packageName}`, { cause: error }) } } /** Resolve every explicit runtime export that an out-of-tree plugin can import. */ function packageProxySource( - installAnchor: string, packageName: string, packageDir: string, ): { version: string; targets: Record } { const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { exports?: unknown + main?: unknown version?: unknown } if (typeof manifest.version !== 'string' || manifest.version.length === 0) { throw new Error(`dsh: installed package ${packageName} must declare a non-empty version`) } const declared = manifest.exports + if (declared === undefined) { + const entry = join(packageDir, typeof manifest.main === 'string' ? manifest.main : 'index.js') + if (!existsSync(entry)) { + throw new Error(`dsh: installed package ${packageName} main entry is missing at ${entry}`) + } + return { version: manifest.version, targets: { '.': pathToFileURL(entry).href } } + } const subpaths = declared !== null && typeof declared === 'object' && !Array.isArray(declared) && Object.keys(declared).some(key => key.startsWith('.')) ? Object.keys(declared).filter(key => key === '.' || (key.startsWith('./') && !key.includes('*') && key !== './package.json')) @@ -288,8 +317,7 @@ function packageProxySource( const targets: Record = {} for (const subpath of subpaths) { const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) - const entry = packageEntryFromAnchor(installAnchor, specifier) - if (entry !== undefined) targets[subpath] = pathToFileURL(entry).href + targets[subpath] = packageEntryFromPackage(packageName, packageDir, specifier) } return { version: manifest.version, targets } } @@ -328,18 +356,13 @@ function ensureModuleProxy( stat = undefined } if (stat !== undefined) { - const marker = join(link, 'package.json') - let existing: ModuleProxyRecord | undefined - try { - existing = JSON.parse(readFileSync(marker, 'utf8')) as ModuleProxyRecord - } catch { - existing = undefined - } + const existing = readModuleProxyRecord(link) if (existing?.dsh?.moduleFallback?.targets === undefined) { throw new Error(`dsh: ${link} exists and is not a dsh-managed module proxy; remove it so dsh can manage the installation fallback`) } if (existing.version === version - && JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets)) return + && JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets) + && Object.keys(targets).every((_, index) => existsSync(join(link, `entry-${index}.js`)))) return rmSync(link, { recursive: true }) } mkdirSync(link, { recursive: true }) @@ -357,10 +380,12 @@ function ensureModuleProxy( * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one * entry per package in the dsh app's resolvable dependency CLOSURE (BFS * over `dependencies` from the app manifest), each resolved from its own - * installation location. Plain Node uses symlinks. A pkg executable writes - * small ESM proxy packages instead because the host filesystem cannot follow - * a symlink into pkg's virtual `/snapshot` tree; the proxy re-exports the - * virtual URL, preserving the executable's single module instance. Node's + * installation location. Plain Node uses symlinks. A pkg executable resolves + * exports under ESM import conditions and writes small proxy packages because + * the host filesystem cannot follow a symlink into pkg's virtual `/snapshot` + * tree; the proxy re-exports the virtual URL, preserving the executable's + * single module instance. One cross-process writer lock prevents partial + * proxies and serializes carrier transitions. Node's * parent-directory walk from any profile finds this * directory after the profile's own `node_modules`, so every in-box plugin * resolves without pnpm ever managing it — the exact "bundles come from the @@ -375,11 +400,20 @@ function ensureModuleProxy( * reused because resolution cannot discover it. * @param installAnchor - absolute path of the dsh app's package.json. * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @returns settlement after the locked fallback generation is complete. */ -export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void { +export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise { const profilesDir = join(home, PROFILES_DIR) const modulesDir = join(profilesDir, 'node_modules') mkdirSync(modulesDir, { recursive: true }) + await withFileLock(modulesDir, () => { + healProfilesModuleFallbackLocked(installAnchor, modulesDir) + return Promise.resolve() + }) +} + +/** Heal one module-fallback generation while the cross-process writer lock is held. */ +function healProfilesModuleFallbackLocked(installAnchor: string, modulesDir: string): void { const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest const links = new Map() /* v8 ignore next -- a real app manifest always declares its name */ @@ -407,7 +441,7 @@ export function healProfilesModuleFallback(installAnchor: string, home: string = const link = join(modulesDir, packageName) mkdirSync(dirname(link), { recursive: true }) if (isPackagedExecutable()) { - const source = packageProxySource(installAnchor, packageName, target) + const source = packageProxySource(packageName, target) if (Object.keys(source.targets).length > 0) { ensureModuleProxy(link, packageName, source.version, source.targets) } diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 3710859380..6eb37bc65a 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -4,9 +4,10 @@ * empty-root composition, and the installation module-fallback healing. */ -import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import { describe, expect, it } from 'vitest' import { composeEntries, @@ -271,7 +272,7 @@ describe('composeEntries', () => { }) describe('healProfilesModuleFallback', () => { - it('links the app and bundle dependency surface flat under profiles/node_modules', () => { + it('links the app and bundle dependency surface flat under profiles/node_modules', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } }, 'plain-lib': {}, @@ -285,7 +286,7 @@ describe('healProfilesModuleFallback', () => { mkdirSync(join(modules, 'dep-of-a'), { recursive: true }) writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' })) const home = tmp() - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) const fallback = join(home, 'profiles', 'node_modules') // App deps, the bundle's own deps, and the bundle itself are linked; the // plain library is linked as an app dep (harmless), the app itself too. @@ -293,42 +294,67 @@ describe('healProfilesModuleFallback', () => { expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true) } // Idempotent, and a moved target is re-pointed. - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) const before = readlinkSync(join(fallback, 'dep-of-a')) expect(before).toContain('dep-of-a') }) - it('throws when a fallback entry is a real directory', () => { + it('throws when a fallback entry is a foreign file or directory', async () => { const anchor = stageInstallation({}) - const home = tmp() - mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true }) - expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink') + for (const kind of ['file', 'directory']) { + const home = tmp() + const entry = join(home, 'profiles', 'node_modules', 'dsh-app') + mkdirSync(join(entry, '..'), { recursive: true }) + if (kind === 'directory') mkdirSync(entry) + else writeFileSync(entry, '') + await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow('is not a symlink') + } }) - it('replaces a wrong symlink', () => { + it('replaces a wrong symlink', async () => { const anchor = stageInstallation({}) const home = tmp() const fallback = join(home, 'profiles', 'node_modules') mkdirSync(fallback, { recursive: true }) symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction') - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') }) - it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => { - // The EEXIST arm: a second process wrote the link between our lstat miss - // and symlinkSync. Simulated by pre-creating the correct link and calling - // the internal path through a stale-lstat shim is not possible from - // outside, so probe the observable contract: healing twice concurrently - // is a no-op, and a foreign REAL directory still fails loud. + it('serializes concurrent healers and retains the identical link', async () => { const anchor = stageInstallation({}) const home = tmp() - healProfilesModuleFallback(anchor, home) - healProfilesModuleFallback(anchor, home) // second healer sees the correct link + await Promise.all([ + healProfilesModuleFallback(anchor, home), + healProfilesModuleFallback(anchor, home), + ]) const fallback = join(home, 'profiles', 'node_modules') expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) }) + it('waits for the module-fallback writer lock before publishing entries', async () => { + const anchor = stageInstallation({}) + const home = tmp() + const modules = join(home, 'profiles', 'node_modules') + mkdirSync(modules, { recursive: true }) + let releaseLock: (() => void) | undefined + let reportLock: (() => void) | undefined + const lockHeld = new Promise((resolve) => { reportLock = resolve }) + const release = new Promise((resolve) => { releaseLock = resolve }) + const holder = withFileLock(modules, async () => { + reportLock?.() + await release + }) + await lockHeld + + const healer = healProfilesModuleFallback(anchor, home) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(existsSync(join(modules, 'dsh-app'))).toBe(false) + releaseLock?.() + await Promise.all([holder, healer]) + expect(lstatSync(join(modules, 'dsh-app')).isSymbolicLink()).toBe(true) + }) + it('writes real ESM proxies for a packaged executable', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') @@ -339,7 +365,7 @@ describe('healProfilesModuleFallback', () => { const home = tmp() Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) const fallback = join(home, 'profiles', 'node_modules') const proxy = join(fallback, 'bundle-a') expect(lstatSync(proxy).isDirectory()).toBe(true) @@ -355,13 +381,79 @@ describe('healProfilesModuleFallback', () => { expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js')) await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' }) - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) } finally { delete (process as NodeJS.Process & { pkg?: unknown }).pkg } }) - it('requires a package version before writing a packaged proxy', () => { + it('resolves import-only exports from each package installation', async () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '[]\n', deps: { 'nested-esm': '0.0.0' } }, + }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + bundleManifest.exports = { '.': { import: './index.js' } } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) + const nestedDir = join(bundleDir, 'node_modules', 'nested-esm') + mkdirSync(nestedDir, { recursive: true }) + writeFileSync(join(nestedDir, 'package.json'), JSON.stringify({ + name: 'nested-esm', + version: '0.0.0', + type: 'module', + exports: { import: './index.js' }, + })) + writeFileSync(join(nestedDir, 'index.js'), 'export const nested = "proxied"\n') + const home = tmp() + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + await expect(import(join(fallback, 'bundle-a', 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) + await expect(import(join(fallback, 'nested-esm', 'entry-0.js'))).resolves.toMatchObject({ nested: 'proxied' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('uses the legacy index fallback when a package has no exports or main', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + delete manifest.main + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js'))) + .resolves.toMatchObject({ packageName: 'bundle-a' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('fails loud on a missing legacy main entry or ESM export target', async () => { + for (const mode of ['legacy', 'exports']) { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + if (mode === 'legacy') delete manifest.main + else manifest.exports = { '.': { import: './missing.js' } } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + rmSync(join(bundleDir, 'index.js')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow( + mode === 'legacy' ? 'main entry is missing' : 'cannot resolve ESM export bundle-a', + ) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + } + }) + + it('requires a package version before writing a packaged proxy', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record @@ -369,7 +461,7 @@ describe('healProfilesModuleFallback', () => { writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - expect(() => { healProfilesModuleFallback(anchor, tmp()) }).toThrow( + await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow( 'installed package bundle-a must declare a non-empty version', ) } finally { @@ -377,23 +469,23 @@ describe('healProfilesModuleFallback', () => { } }) - it('replaces plain-node links and stale managed proxies in packaged mode', () => { + it('replaces plain-node links and stale managed proxies in packaged mode', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const home = tmp() - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') expect(lstatSync(proxy).isSymbolicLink()).toBe(true) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) expect(lstatSync(proxy).isDirectory()).toBe(true) const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { version: string } stale.version = 'stale' writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale)) - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({ version: '0.0.0', }) @@ -402,7 +494,23 @@ describe('healProfilesModuleFallback', () => { } }) - it('rejects foreign packaged fallback directories with valid or invalid metadata', () => { + it('replaces a managed packaged proxy with a plain-node symlink', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules', 'bundle-a') + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback(anchor, home) + expect(lstatSync(fallback).isDirectory()).toBe(true) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + + await healProfilesModuleFallback(anchor, home) + expect(lstatSync(fallback).isSymbolicLink()).toBe(true) + }) + + it('rejects foreign packaged fallback directories with valid or invalid metadata', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { @@ -411,7 +519,7 @@ describe('healProfilesModuleFallback', () => { const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') mkdirSync(proxy, { recursive: true }) writeFileSync(join(proxy, 'package.json'), metadata) - expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow( + await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow( 'exists and is not a dsh-managed module proxy', ) } diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index 6f0e03b8fb..c866abbc25 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../util/atomic-write" + }, { "path": "../../util/launch-environment" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9deab88d6b..c7c4e7d7f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1145,6 +1145,12 @@ importers: packages/boot/app-boot: dependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write + import-meta-resolve: + specifier: ^4.2.0 + version: 4.2.0 js-yaml: specifier: ^4.2.0 version: 4.2.0 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 7a8ef7c0fd..29b3a5a70b 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: edf5ced63f181bb17895d81cc060577916554305 -README.zh.md: e03a19e5b8f0cef2e2f9d86f8d91b8808e268ff4 +README.md: ef2ba1764aa93fc6499941dd372a1de94f33d454 +README.zh.md: b5c36b6ff0fbce303649ae92efd7e1edac954e82 diff --git a/python/sdk/README.md b/python/sdk/README.md index edf5ced63f..ef2ba1764a 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -26,7 +26,7 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. +`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 10-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. ## Customize plugins diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index e03a19e5b8..b5c36b6ff0 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -26,7 +26,7 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 +`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 10 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 ## 自定义插件 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 1b33524118..09286a1ad1 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -29,6 +29,7 @@ class DeepSeekHarnessConfig: patches: tuple[str, ...] = () dsh_home: str | None = None env: dict[str, str] = field(default_factory=dict) + initialize_timeout_seconds: float = 10.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 base_url: str | None = None @@ -79,6 +80,7 @@ class DeepSeekHarness: dsh_home=self.config.dsh_home, cwd=runtime_cwd, env=env, + initialize_timeout_seconds=self.config.initialize_timeout_seconds, request_timeout_seconds=self.config.request_timeout_seconds, shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, ), diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 8969bc7901..5978d849ab 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -31,6 +31,7 @@ class HarnessConfig: dsh_home: str | None = None cwd: str | None = None env: dict[str, str] | None = None + initialize_timeout_seconds: float = 10.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 _launch_args: tuple[str, ...] | None = None @@ -137,7 +138,15 @@ class HarnessClient: if max_tokens is not None: payload["maxTokens"] = max_tokens try: - return self.request("initialize", payload, response_model=InitializeResponse) + return self.request( + "initialize", + payload, + response_model=InitializeResponse, + timeout_seconds=self.config.initialize_timeout_seconds, + ) + except TimeoutError as error: + self.close() + raise TimeoutError(f"{error}\nselected dsh profile {self.config.profile!r}") from error except BaseException as error: self.close() diagnostics = self._runtime_diagnostics() diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index f1190407c9..1ad89ffd4b 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -731,7 +731,8 @@ time.sleep(60) with HarnessClient( HarnessConfig( _launch_args=(sys.executable, str(script)), - request_timeout_seconds=0.1, + profile="web", + initialize_timeout_seconds=0.1, ) ) as client: start = time.monotonic() @@ -740,6 +741,7 @@ time.sleep(60) except TimeoutError as exc: assert time.monotonic() - start < 2 assert "bridge is still starting" in str(exc) + assert "profile 'web'" in str(exc) else: raise AssertionError("initialize should time out") @@ -832,6 +834,8 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None: assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set( HarnessConfig.__dataclass_fields__ ) + assert "initialize_timeout_seconds" in DeepSeekHarnessConfig.__dataclass_fields__ + assert "initialize_timeout_seconds" in HarnessConfig.__dataclass_fields__ for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"): assert removed not in DeepSeekHarnessConfig.__dataclass_fields__ assert removed not in HarnessConfig.__dataclass_fields__ diff --git a/scripts/build-exe-for-python-sdk-assets.spec.ts b/scripts/build-exe-for-python-sdk-assets.spec.ts new file mode 100644 index 0000000000..27167c5307 --- /dev/null +++ b/scripts/build-exe-for-python-sdk-assets.spec.ts @@ -0,0 +1,27 @@ +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '..') +const script = resolve(root, 'scripts/build-exe-for-python-sdk.ts') + +describe('Python runtime executable assets', () => { + it('packages the dynamically resolved web frontend distribution', () => { + const result = spawnSync(process.execPath, [ + '--import', + 'tsx/esm', + script, + '--skip-build', + '--dry-run', + '--targets=node24-macos-arm64', + ], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, npm_execpath: 'C:\\tools\\pnpm.cjs' }, + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*') + expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*') + }) +}) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index f7d696edfe..c8c30b4301 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -47,6 +47,7 @@ const ASSET_GLOBS = [ 'node_modules/**/*.mjs', 'node_modules/**/package.json', 'node_modules/**/*.json', + // Package-owned Markdown includes runtime skill instructions and badge content. 'node_modules/**/*.md', 'node_modules/**/*.dylib', 'node_modules/**/*.dll', @@ -56,6 +57,10 @@ const ASSET_GLOBS = [ 'node_modules/**/*.wasm', 'node_modules/**/*.yaml', 'node_modules/**/*.yml', + // web-app builds this path dynamically, so pkg cannot discover the static frontend. + 'node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*', + // skill-badge resolves both Markdown and image resources through import.meta.url. + 'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*', ] const PLATFORMS = ['linux', 'macos'] as const From d801f262d84b05c3af1f54ef8e9ce173cbd1dd2c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:59:49 +0800 Subject: [PATCH 065/138] fix(python-sdk): resolve packaged proxies from real module entries The packaged dsh launcher must expose installation modules to profile-local plugins without writing symlinks into pkg's virtual filesystem. The first review fix selected ESM exports correctly in ordinary Node, but real carrier execution exposed package metadata and VFS behavior that a synthetic tree did not cover: executable and declaration packages have no import entry, legacy main fields rely on Node probing, and pkg's Windows VFS prevents filesystem package-scope resolution from seeing exports such as zod/mini and @google/genai/web. Resolve explicit exports directly from each installed manifest with the maintained resolve.exports package under Node import conditions. Publish only package-local candidate files that exist, reject escaping or malformed targets, preserve the package installation URL without realpath, and keep Node's legacy resolver only for exports-less packages. This avoids pkg filesystem package lookup entirely while retaining fail-loud behavior for broken runtime entries. Add regression coverage for import-only, nested, symlinked, zod-style, and genai-style condition maps; unavailable and types-only entries; invalid and escaping targets; executable/declaration packages; extensionless main; and legacy index fallback. profile.ts remains at 100% statements, branches, functions, and lines. Update the bilingual package and Agent Note contracts, replace the runtime dependency and generated notice, and regenerate the lockfile through pnpm. --- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/package.json | 4 +- packages/boot/app-boot/src/profile.ts | 69 ++++++--- packages/boot/app-boot/tests/profile.spec.ts | 145 ++++++++++++++++-- pnpm-lock.yaml | 12 +- 11 files changed, 206 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 17e40be1db..158540a66f 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 14c58c85923aac1b54362c72f61f7f1e1c145124 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 9f584ca9bfcc7907caede70ef7e9781f2bc37aab +2026-08-23-python-sdk-dsh-profile-runtime.md: 74484f46b59c3395d7807ae6819026ad8793d033 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 0bc9b65115a18411b83bec4e791c2ddff41e76e1 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 14c58c8592..74484f46b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -30,7 +30,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. -Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit runtime exports under ESM import conditions and re-exports its virtual module URLs. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. +Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 9f584ca9bf..0bc9b65115 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -30,7 +30,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 -普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理按 ESM import 条件解析源包的显式运行时 exports,并重新导出其虚拟模块 URL。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 +普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 347f3b035e..2b2a3019c8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,7 +66,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | -| [`import-meta-resolve`](https://github.com/wooorm/import-meta-resolve) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | @@ -89,6 +88,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | | [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | +| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 6bd721e291..93f5f1ae4c 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: f48abfa445e4ac604b5e1bc694f8bae395b566e7 -README.zh.md: e903c74fb12fee0fca1d201d217dd28be8b7e08d +README.md: 1fa00eefae367e2a5a44966d2f2debff9f95c074 +README.zh.md: ef57478b8a3de723e4fa8ce88f87d563eb5a81e5 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index f48abfa445..1fa00eefae 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves exports with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index e903c74fb1..ef57478b8a 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则按 Node ESM import 条件解析 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 96594a9117..d33cae8870 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -33,8 +33,8 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", - "import-meta-resolve": "^4.2.0", - "js-yaml": "^4.2.0" + "js-yaml": "^4.2.0", + "resolve.exports": "^2.0.3" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-group": "workspace:^", diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 3f2a3d11c0..dc7172ea5d 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -24,15 +24,15 @@ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' -import { basename, dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { basename, dirname, join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import { resolve as resolveImport } from 'import-meta-resolve' +import { resolve as resolvePackage, type Package as ResolvePackageManifest } from 'resolve.exports' import { loadOverlayPatches } from './index.ts' /** Directory under the Harness home holding every profile. */ @@ -276,27 +276,44 @@ function isPackagedExecutable(): boolean { return (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined } -/** Resolve one package export with the ESM conditions used by the generated proxy. */ -function packageEntryFromPackage(packageName: string, packageDir: string, specifier: string): string { +/** Resolve one available explicit package export under Node ESM import conditions. */ +function packageEntryFromPackage( + packageName: string, + packageDir: string, + declared: ResolvePackageManifest['exports'], + subpath: string, +): string | undefined { + let candidates: string[] | void try { - const resolved = resolveImport(specifier, pathToFileURL(join(packageDir, 'package.json')).href) - if (!resolved.startsWith('file:') || !existsSync(fileURLToPath(resolved))) { - throw new Error(`resolved to missing or non-file URL ${resolved}`) - } - return resolved + candidates = resolvePackage({ name: packageName, exports: declared }, subpath) } catch (error) { + if ((error as Error).message.startsWith('No known conditions for ')) return undefined + const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) throw new Error(`dsh: cannot resolve ESM export ${specifier} from installed package ${packageName}`, { cause: error }) } + for (const candidate of candidates ?? []) { + const target = candidate + const entry = resolve(packageDir, target) + const relativeEntry = relative(packageDir, entry) + if (!target.startsWith('./') || /^\.\.(?:[\\/]|$)/u.test(relativeEntry)) { + throw new Error(`dsh: installed package ${packageName} export ${subpath} resolves outside its package: ${target}`) + } + if (existsSync(entry) && statSync(entry).isFile()) return pathToFileURL(entry).href + } + return undefined } -/** Resolve every explicit runtime export that an out-of-tree plugin can import. */ +/** Resolve every explicit ESM runtime export that an out-of-tree plugin can import. */ function packageProxySource( packageName: string, packageDir: string, ): { version: string; targets: Record } { const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + bin?: unknown exports?: unknown main?: unknown + types?: unknown + typings?: unknown version?: unknown } if (typeof manifest.version !== 'string' || manifest.version.length === 0) { @@ -304,20 +321,34 @@ function packageProxySource( } const declared = manifest.exports if (declared === undefined) { - const entry = join(packageDir, typeof manifest.main === 'string' ? manifest.main : 'index.js') - if (!existsSync(entry)) { - throw new Error(`dsh: installed package ${packageName} main entry is missing at ${entry}`) + const main = typeof manifest.main === 'string' && manifest.main.length > 0 ? manifest.main : undefined + const entry = join(packageDir, main ?? 'index') + try { + const resolved = createRequire(join(packageDir, 'package.json')).resolve(entry) + return { version: manifest.version, targets: { '.': pathToFileURL(resolved).href } } + } catch (error) { + if (main === undefined + && (manifest.bin !== undefined || manifest.types !== undefined || manifest.typings !== undefined)) { + return { version: manifest.version, targets: {} } + } + throw new Error(`dsh: installed package ${packageName} main entry is missing at ${entry}`, { cause: error }) } - return { version: manifest.version, targets: { '.': pathToFileURL(entry).href } } } const subpaths = declared !== null && typeof declared === 'object' && !Array.isArray(declared) && Object.keys(declared).some(key => key.startsWith('.')) - ? Object.keys(declared).filter(key => key === '.' || (key.startsWith('./') && !key.includes('*') && key !== './package.json')) + ? Object.keys(declared).filter(key => key === '.' || ( + key.startsWith('./') && !key.includes('*') && !key.endsWith('/') && key !== './package.json' + )) : ['.'] const targets: Record = {} for (const subpath of subpaths) { - const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) - targets[subpath] = packageEntryFromPackage(packageName, packageDir, specifier) + const target = packageEntryFromPackage( + packageName, + packageDir, + declared as ResolvePackageManifest['exports'], + subpath, + ) + if (target !== undefined) targets[subpath] = target } return { version: manifest.version, targets } } diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 6eb37bc65a..552bf79174 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -359,7 +359,12 @@ describe('healProfilesModuleFallback', () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record - bundleManifest.exports = { '.': './index.js', './feature': './feature.js' } + bundleManifest.exports = { + '.': './index.js', + './feature': './feature.js', + './legacy/': './legacy/', + './types': { types: './feature.d.ts' }, + } writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) writeFileSync(join(bundleDir, 'feature.js'), 'export const feature = "proxied"\n') const home = tmp() @@ -416,6 +421,61 @@ describe('healProfilesModuleFallback', () => { } }) + it('resolves explicit condition targets without filesystem package lookup', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.exports = { + '.': { import: './index.js', require: './index.cjs' }, + './mini': { types: './mini/index.d.ts', import: './mini/index.js', require: './mini/index.cjs' }, + './web': { types: './dist/web/web.d.ts', import: './dist/web/index.mjs', default: './dist/web/index.mjs' }, + } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + mkdirSync(join(bundleDir, 'mini')) + writeFileSync(join(bundleDir, 'mini', 'index.js'), 'export const mini = true\n') + mkdirSync(join(bundleDir, 'dist', 'web'), { recursive: true }) + writeFileSync(join(bundleDir, 'dist', 'web', 'index.mjs'), 'export const web = true\n') + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ mini: true }) + await expect(import(join(proxy, 'entry-2.js'))).resolves.toMatchObject({ web: true }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('preserves the installation path while resolving packaged exports', async () => { + const anchor = stageInstallation({}) + const appDir = join(anchor, '..') + const physical = tmp() + writeFileSync(join(physical, 'package.json'), JSON.stringify({ + name: 'linked-esm', + version: '0.0.0', + type: 'module', + exports: { import: './index.js' }, + })) + writeFileSync(join(physical, 'index.js'), 'export const linked = true\n') + symlinkSync(physical, join(appDir, 'node_modules', 'linked-esm'), 'junction') + const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record } + appManifest.dependencies['linked-esm'] = '0.0.0' + writeFileSync(anchor, JSON.stringify(appManifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + const proxyManifest = JSON.parse(readFileSync( + join(home, 'profiles', 'node_modules', 'linked-esm', 'package.json'), + 'utf8', + )) as { dsh: { moduleFallback: { targets: Record } } } + expect(proxyManifest.dsh.moduleFallback.targets['.']).toContain('/app/node_modules/linked-esm/index.js') + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + it('uses the legacy index fallback when a package has no exports or main', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') @@ -433,20 +493,87 @@ describe('healProfilesModuleFallback', () => { } }) - it('fails loud on a missing legacy main entry or ESM export target', async () => { - for (const mode of ['legacy', 'exports']) { + it('uses Node legacy resolution for an extensionless main entry', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.main = './index' + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js'))) + .resolves.toMatchObject({ packageName: 'bundle-a' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('skips executable-only and declaration-only packages without import entries', async () => { + for (const marker of ['bin', 'types', 'typings']) { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const manifest = JSON.parse(readFileSync(anchor, 'utf8')) as Record + delete manifest.main + manifest[marker] = marker === 'bin' ? { dsh: './lib/bin.js' } : './index.d.ts' + if (marker === 'types') manifest.main = '' + writeFileSync(anchor, JSON.stringify(manifest)) + rmSync(join(anchor, '..', 'index.js')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + expect(existsSync(join(fallback, 'dsh-app'))).toBe(false) + expect(existsSync(join(fallback, 'bundle-a', 'entry-0.js'))).toBe(true) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + } + }) + + it('fails loud on a missing legacy main entry', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + delete manifest.main + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + rmSync(join(bundleDir, 'index.js')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow('main entry is missing') + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('omits unavailable ESM exports and rejects malformed export targets', async () => { + for (const mode of ['missing', 'directory', 'absent-map', 'invalid', 'escape', 'null', 'null-subpath']) { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record - if (mode === 'legacy') delete manifest.main - else manifest.exports = { '.': { import: './missing.js' } } + const target = mode === 'missing' ? './missing.js' + : mode === 'directory' ? './mini' + : mode === 'escape' ? './../outside.js' + : '../outside.js' + manifest.exports = mode === 'absent-map' ? null + : mode === 'null-subpath' ? { './bad': null } + : { '.': mode === 'null' ? null : { import: target } } writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) - rmSync(join(bundleDir, 'index.js')) + if (mode === 'directory') mkdirSync(join(bundleDir, 'mini')) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow( - mode === 'legacy' ? 'main entry is missing' : 'cannot resolve ESM export bundle-a', - ) + const home = tmp() + if (mode === 'missing' || mode === 'directory' || mode === 'absent-map') { + await healProfilesModuleFallback(anchor, home) + expect(existsSync(join(home, 'profiles', 'node_modules', 'bundle-a'))).toBe(false) + } else { + await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow( + mode === 'null' || mode === 'null-subpath' + ? 'cannot resolve ESM export bundle-a' + : 'resolves outside its package', + ) + } } finally { delete (process as NodeJS.Process & { pkg?: unknown }).pkg } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7c4e7d7f1..daec43dbff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1148,12 +1148,12 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write - import-meta-resolve: - specifier: ^4.2.0 - version: 4.2.0 js-yaml: specifier: ^4.2.0 version: 4.2.0 + resolve.exports: + specifier: ^2.0.3 + version: 2.0.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -14965,6 +14965,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -20607,6 +20611,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} + retry@0.13.1: {} rfdc@1.4.1: {} From c2ad69344f288034f19d246d73ea808b37b80724 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:10:12 +0800 Subject: [PATCH 066/138] fix(python-sdk): make the minimal profile an explicit allowlist Give the SDK JSON-RPC server a per-root tool filter and let deployments mark the configured persona as the complete system prompt. The checked-in minimal overlay now names only bash and str_replace_editor, so later global tools and unrelated guidance from dsh-base cannot appear implicitly. Keep the shared SDK host services and packaged Web capability intact. Only workspace instructions, compaction, and the conflicting one-shot Bash row remain disabled. Unit coverage pins the configuration paths, and a real dsh profile smoke proves the assembled prompt and exact two-tool request. --- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 + ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 20 +++- docs/config-catalog.zh.md | 22 ++++- docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 2 +- docs/user/guide/python-sdk.zh.md | 2 +- examples/python-sdk-agent/README.i18n.yaml | 4 +- examples/python-sdk-agent/README.md | 4 +- examples/python-sdk-agent/README.zh.md | 4 +- examples/python-sdk-agent/minimal.patch.yml | 49 +++------- .../tests/keyless-smoke.e2e.ts | 92 +++++++++++++++++++ packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 1 + packages/core/system-prompt/README.zh.md | 1 + packages/core/system-prompt/src/index.ts | 4 + .../system-prompt/tests/system-prompt.spec.ts | 15 +++ .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 4 +- .../examples/agent-spine-demo/README.zh.md | 4 +- .../examples/agent-spine-demo/src/index.ts | 13 ++- .../agent-spine-demo/tests/agent-core.spec.ts | 4 + packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 4 +- packages/sdk/server/README.zh.md | 4 +- packages/sdk/server/src/index.ts | 13 +++ packages/sdk/server/src/server.ts | 7 ++ .../sdk/server/tests/plugin-apply.spec.ts | 54 ++++++++++- packages/sdk/server/tests/server.spec.ts | 41 +++++++++ scripts/smoke-python-runtime.py | 24 ++--- 32 files changed, 327 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 158540a66f..2082ebf627 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 74484f46b59c3395d7807ae6819026ad8793d033 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 0bc9b65115a18411b83bec4e791c2ddff41e76e1 +2026-08-23-python-sdk-dsh-profile-runtime.md: 19c870b3b0e10b25480bacc85b9db29b01d2577d +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 404798ff4ae1fcafbaa8403c7187297adf374a19 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 74484f46b5..19c870b3b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -24,6 +24,8 @@ Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. +The checked-in minimal overlay retains the shared SDK profile while configuring the server with a root-agent tool allow list. Later base-bundle tools remain unavailable unless the overlay names them. Its deployment persona is a complete system prompt, so unrelated guidance sections cannot describe hidden tools; dynamic runtime context, workspace instructions, compaction, and the conflicting one-shot Bash row are disabled separately. + The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. ### Executable packaging diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 0bc9b65115..404798ff4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -24,6 +24,8 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 +检入的极简 overlay 会保留共享 SDK profile,并为 server 配置根 agent 工具 allow 列表。基础 bundle 后续新增的工具只有在 overlay 指名时才可用。其部署 persona 是完整系统提示词,因此无关引导段不会描述已隐藏的工具;动态运行时上下文、workspace 指令、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。 + 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 ### 可执行程序打包 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9dc39c909c..4998dcd505 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: acd32b7e76180f13956ec8a54ca16d0ef99bf203 -config-catalog.zh.md: eacca1575191cff6dde93cf80e5ac576e47f485c +config-catalog.md: cb156837fa0768dfa74d9b351cc3e362449ca248 +config-catalog.zh.md: 151a72190332a400b643111495d36e2d7f205d93 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index acd32b7e76..cb156837fa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -166,9 +166,10 @@ Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/ag * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -196,6 +197,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -247,7 +250,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -1689,6 +1692,13 @@ Requires: `agents` export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -2414,6 +2424,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index eacca15751..151a721903 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -168,9 +168,10 @@ export type PresetTrust = 'system' | 'user' * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -198,6 +199,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -249,7 +252,7 @@ export interface GoalConfig { 依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -1691,6 +1694,13 @@ export interface Config { export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -1702,7 +1712,7 @@ export interface JsonRpcConfig { 依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`) -来源:[`packages/sdk/server/src/index.ts:29`](../packages/sdk/server/src/index.ts) +来源:[`packages/sdk/server/src/index.ts:25`](../packages/sdk/server/src/index.ts) @@ -2416,6 +2426,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index bbb6cff7ec..69e9720f11 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/python-sdk.md -python-sdk.md: 4fbc6b343714b232b90a7954b35bf6863716281b -python-sdk.zh.md: 51637ed986d298d46633891077bf2a9d27c35bdd +python-sdk.md: 98d7f8160dee0874338c1d914796dcfb72400a48 +python-sdk.zh.md: 8a0417b24be859b8d327a8650ce1db23adb4dba2 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 4fbc6b3437..98d7f8160d 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -101,7 +101,7 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe | Context compaction | Disabled | | Session persistence | Zstandard JSONL under `/sessions` | -The overlay removes runtime-context prompt messages and most default tools while retaining the SDK application's protocol and persistence. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. +The overlay allowlists persistent Bash and the editor for every SDK-created root agent, so later base-profile tools cannot appear implicitly. It suppresses unrelated prompt sections and runtime-context messages, disables local instruction discovery and compaction, and retains the SDK application's protocol, persistence, policy, settings, credentials, and providers. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. Use a fresh home when profiles, plugins, credentials, settings, and sessions must be isolated. Use a fresh session id for independent work; reuse a harness, home, and id only to continue the same durable conversation and session-owned resources. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 51637ed986..8a0417b24b 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -101,7 +101,7 @@ dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle | 上下文压缩 | 禁用 | | 会话持久化 | `/sessions` 下的 Zstandard JSONL | -该 overlay 会移除运行时上下文提示消息与大多数默认工具,但保留 SDK 应用的协议与持久化。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 +该 overlay 会为每个由 SDK 创建的根 agent allowlist 持久 Bash 与 editor,因此基础 profile 以后新增的工具不会隐式出现。它会抑制无关提示词段与运行时上下文消息,停用本地指令发现与 compaction,并保留 SDK 应用的协议、持久化、策略、settings、credentials 与 provider。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 需要隔离 profile、插件、凭据、设置与会话时,应使用新的 home。独立工作应使用新的 session id;只有继续同一段持久对话和会话资源时,才同时复用 harness、home 与 id。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index 1f9b897250..f0f1ef1980 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-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 examples/python-sdk-agent/README.md -README.md: e46765b0419b0b614163fa507cadef0e5c5a53b9 -README.zh.md: 13c297bdbf01b612369ded5909ec8b7f71bab26c +README.md: b1718bfa3fb6ea4cb241bb4211b58a21bacc17a2 +README.zh.md: dbc4053093f66a60a18ab02bb727611a279fe03c diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index e46765b041..b1718bfa3f 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -19,12 +19,12 @@ python examples/python-sdk-agent/minimal.py \ Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` and `--profile` override their script defaults. The selected home stores the generated profile and Zstandard session logs under `sessions/`; the script never reads `~/.dsh` implicitly. -[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. It preserves the SDK application bundle but narrows model-visible behavior to: +[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. Its root-agent tool allowlist exposes exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -The patch omits Harness identity and runtime-context messages, local instruction discovery, skills, compaction, plan/goal/task/web/subagent/workflow tools, and the profile's one-shot Bash. It inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. +The allowlist excludes every other current or later global tool without requiring a disable entry for each base row. A complete deployment persona suppresses unrelated tool-guidance sections; runtime-context messages, local instruction discovery, compaction, and the conflicting one-shot Bash row are disabled separately. The overlay inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. Other SDK-profile services remain mounted, including persistence, policy, settings, credentials, and providers. This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index 13c297bdbf..dbc4053093 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -19,12 +19,12 @@ python examples/python-sdk-agent/minimal.py \ 兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 与 `--profile` 会覆盖脚本默认值。所选 home 保存生成的 profile,并在 `sessions/` 下保存 Zstandard 会话日志;脚本绝不会隐式读取 `~/.dsh`。 -[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。它保留 SDK 应用 bundle,但将模型可见行为收窄为: +[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。其根 agent 工具 allow 列表只暴露: - agent 所有的持久 `bash` - 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -该 patch 会省略 Harness 身份与运行时上下文消息、本地指令发现、skill、compaction,以及 plan/goal/task/web/subagent/workflow 工具和 profile 的单次 Bash。它插入本地 PTY 与持久 Bash provider,并把 editor 输出上限设为 16,000 字符。 +Allow 列表会排除当前及以后出现的其他所有全局工具,无需为每个基础配置项添加 disable。完整部署 persona 会抑制无关工具的引导段;运行时上下文消息、本地指令发现、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。该 overlay 会插入本地 PTY 与持久 Bash provider,并将 editor 输出上限设为 16,000 字符。其他 SDK profile 服务仍保持挂载,包括持久化、策略、settings、credentials 与 provider。 此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 diff --git a/examples/python-sdk-agent/minimal.patch.yml b/examples/python-sdk-agent/minimal.patch.yml index be3f292cc0..2c3ac0f5bc 100644 --- a/examples/python-sdk-agent/minimal.patch.yml +++ b/examples/python-sdk-agent/minimal.patch.yml @@ -1,49 +1,28 @@ -# Minimal Python SDK overlay for `dsh --profile sdk`: keep only persistent -# Bash and the string-replacement editor, with no runtime-context prompt or -# compaction. The profile still owns JSON-RPC serving and persistence. +# Minimal Python SDK overlay for `dsh --profile sdk`. The SDK server applies +# the model-facing allowlist to every root session, so later base-bundle tools +# cannot appear implicitly. The profile still owns JSON-RPC and persistence. - id: system-prompt config: includeHarnessIdentity: false includeRuntimeContext: false persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' + personaComplete: true +- id: sdk-jsonrpc-server + config: + maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" + toolFilter: + allow: + - bash + - str_replace_editor + +# Remove non-tool model inputs and turn rewriting, plus the one-shot Bash row +# that would conflict with the persistent Bash tool inserted below. - id: agent-instructions disabled: true -- id: skill-filesystem - disabled: true -- id: tool-skill - disabled: true - id: tool-bash disabled: true -- id: tool-jobs - disabled: true -- id: tool-fs - disabled: true -- id: tool-fs-search - disabled: true -- id: tool-subagent-control - disabled: true -- id: tool-subagent-list-agents - disabled: true -- id: tool-subagent - disabled: true -- id: tool-subagent-fork - disabled: true -- id: tool-subagent-report - disabled: true -- id: tool-workflow - disabled: true -- id: tool-todo - disabled: true -- id: tool-goal - disabled: true -- id: tool-ralph - disabled: true -- id: tool-web - disabled: true -- id: plan-mode - disabled: true - id: compaction-basic disabled: true - id: command-compact diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index c0aabb48b8..4b2519c7f8 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) +const minimalPatchPath = fileURLToPath(new URL('../minimal.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -180,6 +181,97 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) + it('keeps the minimal overlay on an explicit model-facing allowlist', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) + const modelRequests: Record[] = [] + const modelServer = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + modelRequests.push(JSON.parse(body) as Record) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.end('data: [DONE]\n\n') + }) + }) + await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) + const address = modelServer.address() + if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') + const child = execa(process.execPath, [ + '--import', + 'tsx/esm', + binScript, + '--profile', + 'sdk', + '--patch', + minimalPatchPath, + ], { + cwd: repoRoot, + env: { + DSH_HOME: join(root, '.dsh'), + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'keyless-smoke-no-call', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + }, + timeout: 35_000, + killSignal: 'SIGKILL', + reject: false, + }) + const lines: string[] = [] + let stdoutBuffer = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') + const parts = stdoutBuffer.split('\n') + stdoutBuffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + + try { + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + })}\n`) + await waitForLine(lines, value => value.id === 1, () => stderr) + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, + })}\n`) + await waitForLine(lines, (value) => { + const params = value.params as Record | undefined + const event = params?.event as Record | undefined + return params?.sessionId === 'minimal' && event?.type === 'turn/end' + }, () => stderr) + + const request = modelRequests[0] as { + messages?: Array<{ role?: string; content?: unknown }> + tools?: Array<{ function?: { name?: string } }> + } + expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) + expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual(['bash', 'str_replace_editor']) + + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) + await waitForLine(lines, value => value.id === 3, () => stderr) + const exit = await child + expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) + } finally { + child.kill('SIGKILL') + await child + await new Promise(resolve => modelServer.close(() => { resolve() })) + await rm(root, { recursive: true, force: true }) + } + }, 40_000) + it('rejects an invalid max-token success env value', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-')) try { diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index dd68b6a139..c34ff9547a 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/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/system-prompt/README.md -README.md: d750a507e628e7609af542227e4528d4d4934ce8 -README.zh.md: ec5b32d742b96c8044a3707c35f15e30ba642b4f +README.md: a52aa3e4c2782993fed5a525cc827aba4e3eaeb0 +README.zh.md: cf0ba43aa2f2f47ea61ef13c74fbf182fbd9f2ee diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index d750a507e6..a52aa3e4c2 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -11,6 +11,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | | `includeRuntimeContext` | `true` | Include ordered dynamic contexts in assembly. When false, context providers are not evaluated and contexts added by `system-prompt/assemble` listeners are discarded after the waterfall; other services and their enforcement remain active. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | +| `personaComplete` | `false` | Treat `persona` as the complete system prompt after assembly. Other sections remain registered but are omitted from model requests; tool schemas and variables remain available. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). | ## Service: `SystemPrompt` (ctx key: `systemPrompt`) diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index ec5b32d742..cf0ba43aa2 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -11,6 +11,7 @@ | `includeHarnessIdentity` | `true` | 是否包含顺序为 −100 的固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 | | `includeRuntimeContext` | `true` | 是否在组装中包含有序动态上下文。设为 false 时不会求值上下文提供方,并会在 waterfall 后丢弃 `system-prompt/assemble` 监听器添加的上下文;其他服务及其强制机制仍然生效。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置提供的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(随附循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | +| `personaComplete` | `false` | 在组装后将 `persona` 作为完整系统提示词。其他段仍保持注册,但不会进入模型请求;工具 schema 与变量仍然可用。 | | `toolOrder` | 无 | 显式指定面向模型的工具顺序。该列表由 `ToolSchema.name` 组成,并且必须恰好包含一个 `''` 其余项标记(`TOOL_ORDER_REST`):已列工具按列表位置排列,未列工具则按名称字典序插入该标记所在的位置。缺席 ⇒ 直接按名称字典序排列。该顺序会在 `system-prompt/assemble` waterfall(瀑布式事件)之前应用于已收集的工具。与段的 `order` 排序一样,它会规范化注册表贡献的内容;注册顺序只是插件加载时序的产物。修改列表的 waterfall 监听器对其输出的确定性负责。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在随附循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md)。 | ## 服务:`SystemPrompt`(ctx 键:`systemPrompt`) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index ffc052e0b9..ec36b32432 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -193,6 +193,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names @@ -340,6 +342,7 @@ export class SystemPrompt extends Service { includeHarnessIdentity: z.boolean().default(true), includeRuntimeContext: z.boolean().default(true), persona: z.string().default(''), + personaComplete: z.boolean().default(false), // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) @@ -366,6 +369,7 @@ export class SystemPrompt extends Service { order: PERSONA_ORDER, // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', + complete: config.personaComplete ?? false, }) if (!(config.includeRuntimeContext ?? true)) this.suppressRuntimeContext() } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index cf196892a7..c4018103d3 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -49,6 +49,21 @@ describe('SystemPrompt', () => { expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.') }) + it('can make the deployment persona the complete system prompt', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { + persona: 'You are a focused SDK agent.', + personaComplete: true, + }) + ctx.systemPrompt.section({ name: 'tool:future', order: 100, text: 'Future tool guidance.' }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections).toEqual([ + { name: 'deployment:persona', text: 'You are a focused SDK agent.' }, + ]) + expect(renderPrompt(assembly)).toBe('You are a focused SDK agent.') + }) + it('can suppress runtime context without evaluating providers or accepting waterfall additions', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, { includeRuntimeContext: false }) diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 1144fb7047..dedce4969d 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 509e4e6d6b5885fb0c685e4ee34fe8078944415b -README.zh.md: 6b429d139a40daa516735b1192af8924e8c9f7cd +README.md: 28e1496a8c941f6524ec4b4dfbfad52d17df5d71 +README.zh.md: c2838bb4cf78d6ac863bac2eba4d2e4df335fa55 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 509e4e6d6b..28e1496a8c 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,11 +55,11 @@ This applies the [Service Definition / Service Provider / Consumer separation](. ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` is forwarded to `dsh-system-prompt` and suppresses all dynamic context snapshots for fresh sessions without disabling their policy services. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services; `personaComplete: true` makes the deployment persona the sole system-prompt section. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../runtime-diagnostics/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 6b429d139a..c2838bb4cf 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会转发给 `dsh-system-prompt`,为新建会话抑制所有动态上下文快照,但不禁用其策略服务。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务;`personaComplete: true` 会让部署 persona 成为唯一系统提示词段。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../runtime-diagnostics/invariants/README.zh.md)。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 87098c38da..a60ebd9e43 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -70,9 +70,10 @@ export interface GoalConfig { * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -100,6 +101,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -185,6 +188,7 @@ export function pickSpineConfig(config: Omit): Omit): Omit { includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are a helpful software engineer assistant.', + personaComplete: true, workspaceContext: false, skills: { enabled: false }, toolBash: false, @@ -723,6 +724,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(ctx.tools.schemas()).toEqual([]) ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'hidden policy' }) expect((await ctx.systemPrompt.assemble()).contexts).toEqual([]) + ctx.systemPrompt.section({ name: 'hidden', order: 100, text: 'hidden guidance' }) expect(renderPrompt(await ctx.systemPrompt.assemble())) .toBe('You are a helpful software engineer assistant.') @@ -736,6 +738,7 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are merged.', + personaComplete: true, toolOrder: ['zulu'], tools: { mode: 'native' as const }, dshHome: '/tmp/dsh-home', @@ -754,6 +757,7 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: appConfig.includeHarnessIdentity, includeRuntimeContext: appConfig.includeRuntimeContext, persona: appConfig.persona, + personaComplete: appConfig.personaComplete, toolOrder: appConfig.toolOrder, tools: appConfig.tools, dshHome: appConfig.dshHome, diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 5301b4b19e..3ffdb5d1e4 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/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/sdk/server/README.md -README.md: 547836ddab99d5c5a5c39c013c38b4bb25ba7e7d -README.zh.md: 97a7e9897fbf338a79c2cb33a8bab809c1cd3899 +README.md: 2fac60b9313c66a8eb1653adb02405f2f5fe4b08 +README.zh.md: ed2c18f0a2ee96fdacdf0d998314d349f0b0264b diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 547836ddab..2fac60b931 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc ## Config -`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. Optional `toolFilter.allow` and `toolFilter.deny` restrict each SDK-created root agent through `ctx.tools.restrict()`. An allow list excludes later global tool registrations that it does not name, so a fixed SDK deployment cannot silently gain model-facing tools when its base bundle expands. Unknown names and an empty filter fail when the first session is created. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. ## stdout is the protocol @@ -30,7 +30,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s #### What the model sees -For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. +For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. A configured `toolFilter` projects that composition's global tool registry before the request is assembled and executed. #### Token effect diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 97a7e9897f..ed2c18f0a2 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -10,7 +10,7 @@ ## 配置 -`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 +`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。可选的 `toolFilter.allow` 与 `toolFilter.deny` 通过 `ctx.tools.restrict()` 限制每个由 SDK 创建的根 agent。Allow 列表会排除之后出现但未指名的全局工具,因此固定的 SDK 部署不会在基础 bundle 扩展时静默获得面向模型的新工具。未知名称与空筛选器会在创建首个会话时明确失败。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 ## stdout 即协议 @@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### 模型看到的内容 -对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 +对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。配置的 `toolFilter` 会在请求组装与执行前投影该组合的全局工具注册表。 #### Token 影响 diff --git a/packages/sdk/server/src/index.ts b/packages/sdk/server/src/index.ts index 963b4fb3bd..17e9d3a89f 100644 --- a/packages/sdk/server/src/index.ts +++ b/packages/sdk/server/src/index.ts @@ -25,6 +25,13 @@ export const inject = ['agents'] export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -35,6 +42,11 @@ export interface JsonRpcConfig { export const Config: Schema = Schema.object({ maxTokensAsSuccess: Schema.boolean().default(false), + // Preserve omission; Schemastery's materialized empty object is not a valid restriction. + toolFilter: Schema.object({ + allow: Schema.array(Schema.string()).default(undefined as unknown as string[]), + deny: Schema.array(Schema.string()).default(undefined as unknown as string[]), + }).default(undefined as unknown as { allow: string[]; deny: string[] }), }) /** @@ -59,6 +71,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const transport = new JsonRpcLineTransport(input, output) const server = new HarnessSdkJsonRpcServer(ctx, transport, { maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, + ...resolvedConfig.toolFilter === undefined ? {} : { toolFilter: resolvedConfig.toolFilter }, }) // Share one exit task so racing shutdown requests cannot dispose the root or diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index ccc3b6192d..a2ec97d9f4 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -13,6 +13,7 @@ import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { InitializeParams, @@ -38,6 +39,8 @@ function subagentParentOf(carrier: Scoped): Agent { export interface HarnessSdkJsonRpcServerOptions { /** Report max-token termination as an accepted result instead of an infrastructure error. */ maxTokensAsSuccess?: boolean + /** Restrict each SDK-created root agent to an explicit subset of global tools. */ + toolFilter?: ToolRestriction } function successStatus(reason: string, options: HarnessSdkJsonRpcServerOptions): 'ok' | 'error' { @@ -220,6 +223,7 @@ export class HarnessSdkJsonRpcServer { // 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 toolFilter = this.options.toolFilter const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, @@ -228,6 +232,9 @@ export class HarnessSdkJsonRpcServer { model: this.model, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, + ...toolFilter === undefined + ? {} + : { setup: (agentCtx: Context) => { agentCtx.tools.restrict(toolFilter) } }, }) const rec: SessionRecord = { handle } this.sessions.set(sessionId, rec) diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts index 28c89c19fe..41954007eb 100644 --- a/packages/sdk/server/tests/plugin-apply.spec.ts +++ b/packages/sdk/server/tests/plugin-apply.spec.ts @@ -11,6 +11,7 @@ import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { defineTool } from '@deepseek-ai/dsh-tools' import * as jsonrpc from '../src/index.ts' /** @@ -71,6 +72,7 @@ async function mountPlugin( writeDelayMs?: number failFlush?: boolean beforeServer?: (ctx: Context) => Promise | void + toolFilter?: jsonrpc.JsonRpcConfig['toolFilter'] } = {}, ): Promise { const ctx = new Context() @@ -116,7 +118,12 @@ async function mountPlugin( const exit = (code: number): void => { events.push({ kind: 'exit', code }) } ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness') - const fiber = await ctx.plugin(jsonrpc, { input, output, exit }) + const fiber = await ctx.plugin(jsonrpc, { + input, + output, + exit, + ...options.toolFilter === undefined ? {} : { toolFilter: options.toolFilter }, + }) const frames = (): Record[] => events.flatMap(event => event.kind === 'frame' ? [event.frame] : []) @@ -282,6 +289,51 @@ describe('dsh-sdk-jsonrpc-server plugin apply', () => { } }) + it('applies the configured root-agent tool filter through the Loader plugin', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-tool-filter-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const harness = await mountPlugin(storageDir, { + toolFilter: { allow: ['kept'] }, + beforeServer: (ctx) => { + for (const name of ['kept', 'excluded']) { + ctx.tools.register(defineTool({ + name, + description: name, + parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute: async () => name, + })) + } + }, + }) + try { + harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' } }) + await harness.waitForFrame(frame => frame.id === 1, 'initialize response') + harness.send({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'filtered', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, + }) + await harness.waitForFrame( + frame => frame.method === 'session.status' + && (frame.params as { status?: string } | undefined)?.status === 'idle', + 'filtered session idle status', + ) + + const request = llmServer.requests[0] as { tools?: Array<{ function?: { name?: string } }> } + expect(request.tools?.map(entry => entry.function?.name)).toEqual(['kept']) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) const harness = await mountPlugin(storageDir, { writeDelayMs: 10 }) diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 495f1d90f4..d9527e10d5 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -14,6 +14,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import { defineTool } from '@deepseek-ai/dsh-tools' import { HarnessSdkJsonRpcServer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -171,6 +172,46 @@ describe('HarnessSdkJsonRpcServer', () => { } }) + it('allowlists each root session against current and later global tools', { timeout: 15_000 }, async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-tool-filter-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const ctx = await makeHarness(storageDir) + const tool = (name: string) => defineTool({ + name, + description: name, + parameters: {}, + output: { + schema: { type: 'string' as const }, + render: (_args, value) => [{ type: 'text' as const, text: value }], + }, + execute: async () => name, + }) + ctx.tools.register(tool('kept')) + ctx.tools.register(tool('excluded')) + const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport(), { + toolFilter: { allow: ['kept'] }, + }) + try { + await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' }) + await server.prompt({ sessionId: 'first', contentBlocks: [{ type: 'text', text: 'first' }] }) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) }) + ctx.tools.register(tool('future')) + await server.prompt({ sessionId: 'second', contentBlocks: [{ type: 'text', text: 'second' }] }) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) }) + + expect(llmServer.requests.map((request) => { + const tools = (request as { tools?: Array<{ function?: { name?: string } }> }).tools ?? [] + return tools.map(entry => entry.function?.name) + })).toEqual([['kept'], ['kept']]) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('queues overlapping prompts for one session without blocking other sessions', async () => { const mainFollowup = vi.fn() const mainAgent = ({ diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 06653c5741..3be8a9bcfb 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -985,23 +985,7 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - sessions = dsh_home / "sessions" disabled = [ "agent-instructions", - "skill-filesystem", - "tool-skill", "tool-bash", - "tool-jobs", - "tool-fs", - "tool-fs-search", - "tool-subagent-control", - "tool-subagent-list-agents", - "tool-subagent", - "tool-subagent-fork", - "tool-subagent-report", - "tool-workflow", - "tool-todo", - "tool-goal", - "tool-ralph", - "tool-web", - "plan-mode", "compaction-basic", "command-compact", "tool-result-pruner", @@ -1013,6 +997,14 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - "includeHarnessIdentity": False, "includeRuntimeContext": False, "persona": "You are a helpful software engineer assistant.", + "personaComplete": True, + }, + }, + { + "id": "sdk-jsonrpc-server", + "config": { + "maxTokensAsSuccess": True, + "toolFilter": {"allow": ["bash", "str_replace_editor"]}, }, }, *({"id": row_id, "disabled": True} for row_id in disabled), From ab4e65ba82a48e6bc298ed4f32788cbade3a23ad Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:11:05 +0800 Subject: [PATCH 067/138] perf(app-boot): avoid fallback locks for complete profiles Resolve the installation fallback generation before locking and return immediately when every required symlink or packaged proxy is complete. Parallel SDK rollouts sharing an initialized DSH_HOME therefore do not queue on profiles/node_modules.lock. Missing or stale entries still acquire the cross-process writer lock, recheck the generation, and repair under exclusive ownership. Tests hold the lock to prove the steady-state bypass and verify that a partial repair retains already-correct siblings. --- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 130 ++++++++++++------ packages/boot/app-boot/tests/profile.spec.ts | 41 +++++- 8 files changed, 133 insertions(+), 54 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 2082ebf627..8cc79728eb 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 19c870b3b0e10b25480bacc85b9db29b01d2577d -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 404798ff4ae1fcafbaa8403c7187297adf374a19 +2026-08-23-python-sdk-dsh-profile-runtime.md: e3df2d01e3aef7e6eadaa011d51c9ab87456d35f +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 3d50cac36d469172e91be450cea658f3a4830ccf diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 19c870b3b0..e3df2d01e3 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -32,7 +32,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. -Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. +Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. A complete matching generation returns without acquiring the cross-process writer lock. A missing or stale entry acquires the lock, rechecks the generation, and repairs it without exposing partial proxies; either carrier can replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 404798ff4a..3d50cac36d 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -32,7 +32,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 -普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 +普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。完整且匹配的 generation 不会获取跨进程写入锁。缺失或过期的配置项会获取该锁、重新检查 generation,并在不暴露半成品代理的前提下修复;任一载体都可以替换另一载体留下的受管配置项。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 93f5f1ae4c..ae60e877c0 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 1fa00eefae367e2a5a44966d2f2debff9f95c074 -README.zh.md: ef57478b8a3de723e4fa8ce88f87d563eb5a81e5 +README.md: 0adcb0ac20516b1eea97792a11471a383a3ab429 +README.zh.md: 1da1725b1e0681f83f2c82b82cb9bbaac8ea90cb diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 1fa00eefae..0adcb0ac20 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. A complete matching generation returns without acquiring the writer lock. A missing or stale entry acquires the cross-process lock, rechecks the full generation, and repairs it without exposing partial proxies; either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index ef57478b8a..1da1725b1e 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。完整且匹配的 generation 不会获取写入锁。缺失或过期的配置项会获取跨进程锁、重新检查完整 generation,并在不暴露半成品代理的前提下修复;两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index dc7172ea5d..17c6cf65ab 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -407,44 +407,12 @@ function ensureModuleProxy( } } -/** - * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one - * entry per package in the dsh app's resolvable dependency CLOSURE (BFS - * over `dependencies` from the app manifest), each resolved from its own - * installation location. Plain Node uses symlinks. A pkg executable resolves - * exports under ESM import conditions and writes small proxy packages because - * the host filesystem cannot follow a symlink into pkg's virtual `/snapshot` - * tree; the proxy re-exports the virtual URL, preserving the executable's - * single module instance. One cross-process writer lock prevents partial - * proxies and serializes carrier transitions. Node's - * parent-directory walk from any profile finds this - * directory after the profile's own `node_modules`, so every in-box plugin - * resolves without pnpm ever managing it — the exact "bundles come from the - * installation" contract. The closure (not just direct dependencies) is - * required for out-of-tree plugins: their peer dependencies name Service - * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app - * reaches only through its Service Provider packages. Both a symlink target - * and a proxy's virtual target resolve transitive imports from the original - * package directory, so each package needs one flat fallback entry. - * Idempotent: correct entries are kept and changed installation targets are - * rewritten; under plain Node, a stale dangling link stays until its name is - * reused because resolution cannot discover it. - * @param installAnchor - absolute path of the dsh app's package.json. - * @param home - the Harness home; defaults to {@link resolveDshHome}. - * @returns settlement after the locked fallback generation is complete. - */ -export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise { - const profilesDir = join(home, PROFILES_DIR) - const modulesDir = join(profilesDir, 'node_modules') - mkdirSync(modulesDir, { recursive: true }) - await withFileLock(modulesDir, () => { - healProfilesModuleFallbackLocked(installAnchor, modulesDir) - return Promise.resolve() - }) -} +type ModuleFallbackEntry = + | { kind: 'symlink'; packageName: string; packageDir: string } + | { kind: 'proxy'; packageName: string; version: string; targets: Record } -/** Heal one module-fallback generation while the cross-process writer lock is held. */ -function healProfilesModuleFallbackLocked(installAnchor: string, modulesDir: string): void { +/** Resolve the installation generation that every profile must find through the fallback directory. */ +function resolveModuleFallbackEntries(installAnchor: string): ModuleFallbackEntry[] { const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest const links = new Map() /* v8 ignore next -- a real app manifest always declares its name */ @@ -468,16 +436,88 @@ function healProfilesModuleFallbackLocked(installAnchor: string, modulesDir: str queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) } } - for (const [packageName, target] of links) { - const link = join(modulesDir, packageName) + if (!isPackagedExecutable()) { + return [...links].map(([packageName, packageDir]) => ({ kind: 'symlink', packageName, packageDir })) + } + return [...links].flatMap(([packageName, packageDir]) => { + const source = packageProxySource(packageName, packageDir) + return Object.keys(source.targets).length === 0 + ? [] + : [{ kind: 'proxy' as const, packageName, version: source.version, targets: source.targets }] + }) +} + +/** Return whether one existing fallback entry already matches its resolved installation generation. */ +function moduleFallbackEntryCurrent(modulesDir: string, entry: ModuleFallbackEntry): boolean { + const link = join(modulesDir, entry.packageName) + try { + const stat = lstatSync(link) + if (entry.kind === 'symlink') { + return stat.isSymbolicLink() && readlinkSync(link) === entry.packageDir + } + if (!stat.isDirectory()) return false + const existing = readModuleProxyRecord(link) + return existing?.version === entry.version + && JSON.stringify(existing.dsh?.moduleFallback?.targets) === JSON.stringify(entry.targets) + && Object.keys(entry.targets).every((_, index) => existsSync(join(link, `entry-${index}.js`))) + } catch { + return false + } +} + +/** Return whether every required fallback entry is already ready for this installation. */ +function moduleFallbackCurrent(modulesDir: string, entries: readonly ModuleFallbackEntry[]): boolean { + return entries.every(entry => moduleFallbackEntryCurrent(modulesDir, entry)) +} + +/** + * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one + * entry per package in the dsh app's resolvable dependency CLOSURE (BFS + * over `dependencies` from the app manifest), each resolved from its own + * installation location. Plain Node uses symlinks. A pkg executable resolves + * exports under ESM import conditions and writes small proxy packages because + * the host filesystem cannot follow a symlink into pkg's virtual `/snapshot` + * tree; the proxy re-exports the virtual URL, preserving the executable's + * single module instance. A complete matching generation returns without a + * writer lock; actual repairs acquire and recheck one cross-process lock so + * partial proxies and carrier transitions remain serialized. Node's + * parent-directory walk from any profile finds this + * directory after the profile's own `node_modules`, so every in-box plugin + * resolves without pnpm ever managing it — the exact "bundles come from the + * installation" contract. The closure (not just direct dependencies) is + * required for out-of-tree plugins: their peer dependencies name Service + * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app + * reaches only through its Service Provider packages. Both a symlink target + * and a proxy's virtual target resolve transitive imports from the original + * package directory, so each package needs one flat fallback entry. + * Idempotent: correct entries are kept and changed installation targets are + * rewritten; under plain Node, a stale dangling link stays until its name is + * reused because resolution cannot discover it. + * @param installAnchor - absolute path of the dsh app's package.json. + * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @returns settlement after current-state validation or a locked repair. + */ +export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise { + const profilesDir = join(home, PROFILES_DIR) + const modulesDir = join(profilesDir, 'node_modules') + mkdirSync(modulesDir, { recursive: true }) + const entries = resolveModuleFallbackEntries(installAnchor) + if (moduleFallbackCurrent(modulesDir, entries)) return + await withFileLock(modulesDir, () => { + if (!moduleFallbackCurrent(modulesDir, entries)) healProfilesModuleFallbackLocked(entries, modulesDir) + return Promise.resolve() + }) +} + +/** Heal one module-fallback generation while the cross-process writer lock is held. */ +function healProfilesModuleFallbackLocked(entries: readonly ModuleFallbackEntry[], modulesDir: string): void { + for (const entry of entries) { + const link = join(modulesDir, entry.packageName) mkdirSync(dirname(link), { recursive: true }) - if (isPackagedExecutable()) { - const source = packageProxySource(packageName, target) - if (Object.keys(source.targets).length > 0) { - ensureModuleProxy(link, packageName, source.version, source.targets) - } + if (entry.kind === 'proxy') { + ensureModuleProxy(link, entry.packageName, entry.version, entry.targets) } else { - ensureSymlink(link, target) + ensureSymlink(link, entry.packageDir) } } } diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 552bf79174..2e3b4e6187 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -4,7 +4,7 @@ * empty-root composition, and the installation module-fallback healing. */ -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' @@ -321,6 +321,20 @@ describe('healProfilesModuleFallback', () => { expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') }) + it('retains current links while repairing a missing sibling', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules') + await healProfilesModuleFallback(anchor, home) + const appTarget = readlinkSync(join(fallback, 'dsh-app')) + unlinkSync(join(fallback, 'bundle-a')) + + await healProfilesModuleFallback(anchor, home) + + expect(readlinkSync(join(fallback, 'dsh-app'))).toBe(appTarget) + expect(lstatSync(join(fallback, 'bundle-a')).isSymbolicLink()).toBe(true) + }) + it('serializes concurrent healers and retains the identical link', async () => { const anchor = stageInstallation({}) const home = tmp() @@ -332,6 +346,31 @@ describe('healProfilesModuleFallback', () => { expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) }) + it('does not acquire the writer lock for a complete generation', async () => { + const anchor = stageInstallation({}) + const home = tmp() + const modules = join(home, 'profiles', 'node_modules') + await healProfilesModuleFallback(anchor, home) + let releaseLock: (() => void) | undefined + let reportLock: (() => void) | undefined + const lockHeld = new Promise((resolve) => { reportLock = resolve }) + const release = new Promise((resolve) => { releaseLock = resolve }) + const holder = withFileLock(modules, async () => { + reportLock?.() + await release + }) + await lockHeld + + const healer = healProfilesModuleFallback(anchor, home) + const outcome = await Promise.race([ + healer.then(() => 'complete' as const), + new Promise<'blocked'>(resolve => setTimeout(() => { resolve('blocked') }, 100)), + ]) + releaseLock?.() + await Promise.all([holder, healer]) + expect(outcome).toBe('complete') + }) + it('waits for the module-fallback writer lock before publishing entries', async () => { const anchor = stageInstallation({}) const home = tmp() From 8dc3b0380e96a5b1a7c9e2d8b7641a1af8b07e99 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:50:41 +0800 Subject: [PATCH 068/138] feat(bundle): ship the standalone sdk-minimal profile Add a startup-only sdk-minimal template whose sole bundle inserts the complete JSON-RPC agent tree over the empty profile root. The roster is an explicit composition allowlist: it contains one DeepSeek adapter, the minimal agent spine, persistent Bash, the string-replace editor, local execution, and JSONL persistence, while dsh-base and Web remain absent. Reuse the SDK app startup provider so the new profile retains help, stdin EOF, and bounded launcher shutdown semantics. Make that provider render its configured profile name, which keeps both sdk and sdk-minimal help truthful without duplicating process lifecycle code. Register the package in the CLI closure, TypeScript graph, lockfile, Knip policy, and bilingual bundle references. Exact manifest, row-roster, profile-template, config-dump, and HMR tests make later additions visible instead of relying on a blacklist. --- apps/cli/package.json | 1 + apps/cli/tests/built-bin.e2e.ts | 33 +++++++ apps/cli/tests/profile-hmr.spec.ts | 6 +- knip.json | 5 + packages/boot/app-boot/src/profile.ts | 4 + packages/boot/app-boot/tests/profile.spec.ts | 4 + packages/bundle/README.i18n.yaml | 4 +- packages/bundle/README.md | 1 + packages/bundle/README.zh.md | 1 + packages/bundle/sdk-app/README.i18n.yaml | 4 +- packages/bundle/sdk-app/README.md | 6 +- packages/bundle/sdk-app/README.zh.md | 6 +- packages/bundle/sdk-app/cordis.patch.yml | 2 + packages/bundle/sdk-app/package.json | 1 + packages/bundle/sdk-app/src/index.ts | 24 ++++- packages/bundle/sdk-app/tests/startup.spec.ts | 10 +- packages/bundle/sdk-app/tsconfig.json | 3 + packages/bundle/sdk-minimal/README.i18n.yaml | 6 ++ packages/bundle/sdk-minimal/README.md | 31 ++++++ packages/bundle/sdk-minimal/README.zh.md | 31 ++++++ packages/bundle/sdk-minimal/cordis.patch.yml | 97 +++++++++++++++++++ packages/bundle/sdk-minimal/package.json | 67 +++++++++++++ packages/bundle/sdk-minimal/src/index.ts | 9 ++ packages/bundle/sdk-minimal/src/invariant.ts | 26 +++++ .../sdk-minimal/tests/sdk-minimal.spec.ts | 59 +++++++++++ packages/bundle/sdk-minimal/tsconfig.json | 18 ++++ pnpm-lock.yaml | 64 ++++++++++++ tsconfig.host.json | 1 + 28 files changed, 510 insertions(+), 14 deletions(-) create mode 100644 packages/bundle/sdk-minimal/README.i18n.yaml create mode 100644 packages/bundle/sdk-minimal/README.md create mode 100644 packages/bundle/sdk-minimal/README.zh.md create mode 100644 packages/bundle/sdk-minimal/cordis.patch.yml create mode 100644 packages/bundle/sdk-minimal/package.json create mode 100644 packages/bundle/sdk-minimal/src/index.ts create mode 100644 packages/bundle/sdk-minimal/src/invariant.ts create mode 100644 packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts create mode 100644 packages/bundle/sdk-minimal/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 49f7fc84d1..72e81a8208 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-sdk-app": "workspace:^", + "@deepseek-ai/dsh-sdk-minimal": "workspace:^", "@deepseek-ai/dsh-time-context": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 2fffff6700..397e960075 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -12,7 +12,9 @@ import { type SessionNotification, } from '@agentclientprotocol/sdk' import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { execa } from 'execa' +import * as yaml from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ @@ -924,6 +926,37 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) }, 30_000) + it('prints the exact standalone sdk-minimal tree without dsh-base', async () => { + const { stdout, code, stderr } = await runBuiltBin( + ['--profile', 'sdk-minimal', '--dump-default-config'], + { DSH_HOME: home }, + ) + expect(code).toBe(0) + expect(stderr).toBe('') + const rows = yaml.load(stdout, { schema: entryListSchema }) as Array<{ id?: string; name?: string }> + expect(rows.map(row => [row.id, row.name])).toEqual([ + ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'], + ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'], + ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'], + ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'], + ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'], + ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'], + ['sandbox', '@deepseek-ai/dsh-sandbox-local'], + ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], + ['subprocess', '@deepseek-ai/dsh-subprocess-local'], + ['pty', '@deepseek-ai/dsh-terminal'], + ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['fs-local', '@deepseek-ai/dsh-fs-local'], + ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], + ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], + ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ]) + expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') + expect(stdout).not.toContain('@deepseek-ai/dsh-base') + expect(stdout).not.toContain('@deepseek-ai/dsh-web-app') + }, 30_000) + it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) diff --git a/apps/cli/tests/profile-hmr.spec.ts b/apps/cli/tests/profile-hmr.spec.ts index 6fed867d92..306bfe1458 100644 --- a/apps/cli/tests/profile-hmr.spec.ts +++ b/apps/cli/tests/profile-hmr.spec.ts @@ -9,7 +9,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' const REPOSITORY_ROOT = fileURLToPath(new URL('../../../', import.meta.url)) /** Load one shipped bundle patch through the same parser as profile boot. */ -function bundle(name: 'acp-app' | 'base' | 'headless' | 'sdk-app' | 'web-app'): PatchOptions[] { +function bundle(name: 'acp-app' | 'base' | 'headless' | 'sdk-app' | 'sdk-minimal' | 'web-app'): PatchOptions[] { return loadOverlayPatches('profile-hmr test', join(REPOSITORY_ROOT, 'packages', 'bundle', name, 'cordis.patch.yml')) } @@ -39,4 +39,8 @@ describe('profile module-HMR policy', () => { config: { root: ['.'] }, }) }) + + it('keeps the standalone sdk-minimal tree free of module HMR', () => { + expect(composeEntries([bundle('sdk-minimal')]).find(entry => entry.id === 'hmr')).toBeUndefined() + }) }) diff --git a/knip.json b/knip.json index 6e03ebf7da..e8dc6139ee 100644 --- a/knip.json +++ b/knip.json @@ -699,6 +699,11 @@ "@deepseek-ai/dsh-sdk-jsonrpc-server" ] }, + "packages/bundle/sdk-minimal": { + "ignoreDependencies": [ + "@deepseek-ai/.+" + ] + }, "packages/bundle/web-app": { "ignoreDependencies": [ "@deepseek-ai/.+" diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 17c6cf65ab..b99dca29c7 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -146,6 +146,10 @@ export const PROFILE_TEMPLATES: Record = { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'], patchReload: 'startup', }, + 'sdk-minimal': { + bundles: ['@deepseek-ai/dsh-sdk-minimal'], + patchReload: 'startup', + }, } /** Installation-owned bundle tuples normalized to the shipped template. */ diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 2e3b4e6187..4e7a2c5ddf 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -173,6 +173,10 @@ describe('loadProfile', () => { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'], patchReload: 'startup', }) + expect(PROFILE_TEMPLATES['sdk-minimal']).toEqual({ + bundles: ['@deepseek-ai/dsh-sdk-minimal'], + patchReload: 'startup', + }) try { loadProfile('t', 'web', anchor, home) } catch { diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index 5f7fbd40f4..1ca618d528 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/README.md -README.md: d6b24a276fa64bb2eb80c2aad1783795e351ebc4 -README.zh.md: 36acc510cfab7979d28052ab26687dce58175155 +README.md: 44cf46a217e078e4d71d838afb6525f2bc908bd2 +README.zh.md: f2987b72c526698e2df949e2edfe55e1375f2d8e diff --git a/packages/bundle/README.md b/packages/bundle/README.md index d6b24a276f..44cf46a217 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -13,5 +13,6 @@ The manifest declaration, not this directory, defines Bundle identity. Domain pa | [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows | | [`headless/`](headless/README.md) | Direct one-shot task mode over base, with no Host or Web layer | mounts `headless-runner` | | [`sdk-app/`](sdk-app/README.md) | SDK stdio JSON-RPC application over base | mounts the SDK server | +| [`sdk-minimal/`](sdk-minimal/README.md) | Standalone minimal SDK application without base or Web | — (complete patch tree) | In-box bundles resolve from the dsh installation; out-of-tree bundles install into a profile through `dsh plugin --profile add `. diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index 36acc510cf..f2987b72c5 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -13,5 +13,6 @@ Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包 | [`web-app/`](web-app/README.zh.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 | | [`headless/`](headless/README.zh.md) | 直接运行在 base 之上的一次性任务模式,不含 Host 或 Web 层 | 挂载 `headless-runner` | | [`sdk-app/`](sdk-app/README.zh.md) | 运行在 base 之上的 SDK stdio JSON-RPC 应用 | 挂载 SDK server | +| [`sdk-minimal/`](sdk-minimal/README.zh.md) | 不含 base 或 Web 的独立极简 SDK 应用 | 无(完整 patch 树) | 内置组合包从 dsh 安装目录解析;树外(out-of-tree)组合包通过 `dsh plugin --profile add ` 安装进 profile。 diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index 8deaf213fa..43e0f90976 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-app/README.md -README.md: 0356d6f4a99d7baef6ff7619d505392ff7f7f1d2 -README.zh.md: c70eb685954ebff42bca6c3d289ab58e46298d50 +README.md: c5022bd2096fae931fff48944bfc167280e62476 +README.zh.md: 687a19dc09679d11696a207ad17db3cf463ee901 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index 0356d6f4a9..c5022bd209 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -2,10 +2,14 @@ English | [中文](README.zh.md) -The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. +The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider and supplies its own profile name. The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. A deployment selects a different complete composition through profile bundles and patch files, not another app bin. +| Config | Default | Behavior | +|---|---|---| +| `profile` | `sdk` | Profile name rendered in command help; a bundle mounting this provider sets its own shipped profile name. | + `DSH_MAX_TOKENS_AS_SUCCESS` retains the SDK deployment mapping: unset or JSON `true` reports token-limited subagent completion as accepted, while JSON `false` reports it as an error. Provider/model and workspace cwd arrive through the SDK initialization request; the base profile owns adapters, tools, persistence, policy, settings, and credentials. ## Model Experience diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index c70eb68595..687a19dc09 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -2,10 +2,14 @@ [English](README.md) | 中文 -以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。 +以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) 组合包复用同一个启动提供方,并提供自己的 profile 名称。 启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。 +| 配置 | 默认值 | 行为 | +|---|---|---| +| `profile` | `sdk` | 命令 help 中呈现的 profile 名称;挂载此提供方的组合包会设置自己的随附 profile 名称。 | + `DSH_MAX_TOKENS_AS_SUCCESS` 保留 SDK 部署映射:未设置或 JSON `true` 把 token 达限的 subagent 完成报告为已接受,JSON `false` 则报告为错误。模型提供方/模型与工作区 cwd 通过 SDK 初始化请求传入;base profile 拥有适配器、工具、持久化、策略、settings 与 credentials。 ## 模型体验 diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index aa1795168c..373e7aeb63 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -11,6 +11,8 @@ - insert: - id: sdk-app-startup name: '@deepseek-ai/dsh-sdk-app' + config: + profile: sdk - id: sdk-jsonrpc-server name: '@deepseek-ai/dsh-sdk-jsonrpc-server' diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 87214882fb..6ab4167d8d 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -41,6 +41,7 @@ "dependencies": { "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0" }, "peerDependencies": { diff --git a/packages/bundle/sdk-app/src/index.ts b/packages/bundle/sdk-app/src/index.ts index 9ade81a965..fec847af53 100644 --- a/packages/bundle/sdk-app/src/index.ts +++ b/packages/bundle/sdk-app/src/index.ts @@ -7,6 +7,7 @@ import { Command } from 'commander' import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { exitOnStdinEnd, parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ @@ -18,18 +19,30 @@ export const inject = ['cmdlineArgs'] /** Service the JSON-RPC server row waits for before claiming stdio. */ export const SDK_APP_STARTUP_SERVICE = 'sdkAppStartup' +/** SDK stdio startup configuration. */ +export interface Config { + /** Profile name rendered in help and diagnostics (default `sdk`). */ + profile?: string +} + +/** Validate and default SDK stdio startup configuration. */ +export const Config: z = z.object({ + profile: z.string().default('sdk'), +}) + /** * Build this app's zero-option command and help. + * @param profile - selected profile name rendered in the command grammar. * @returns a fresh program for one invocation. */ -function sdkCommand(): Command { +function sdkCommand(profile: string): Command { return new Command() - .name('dsh --profile sdk') + .name(`dsh --profile ${profile}`) .description('Serve DeepSeek Harness SDK clients over stdio JSON-RPC.') .helpOption('-h, --help', 'show this help') .addHelpText('after', ` Example: - dsh --profile sdk serve one SDK runtime until its client disconnects + dsh --profile ${profile} serve one SDK runtime until its client disconnects `) } @@ -37,9 +50,10 @@ Example: * Accept an SDK profile invocation, publish readiness, and bind EOF to the * launcher's bounded shutdown. * @param ctx - plugin context carrying command-line and exit launcher values. + * @param config - selected profile identity for command help. */ -export function apply(ctx: Context): void { - const program = sdkCommand() +export function apply(ctx: Context, config: Config = {}): void { + const program = sdkCommand(config.profile ?? 'sdk') program.action(() => { exitOnStdinEnd(ctx, 'sdk-app.stdin') ctx.provide(SDK_APP_STARTUP_SERVICE, { accepted: true }) diff --git a/packages/bundle/sdk-app/tests/startup.spec.ts b/packages/bundle/sdk-app/tests/startup.spec.ts index 65f2b76c5d..5f0ec6524a 100644 --- a/packages/bundle/sdk-app/tests/startup.spec.ts +++ b/packages/bundle/sdk-app/tests/startup.spec.ts @@ -27,7 +27,7 @@ afterEach(() => { }) /** Run the provider with captured command output and exit requests. */ -function start(args: string[]): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { +function start(args: string[], profile = 'sdk'): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { const ctx = new Context() const exits: number[] = [] const stdin = new TestStdin() @@ -41,7 +41,7 @@ function start(args: string[]): { ctx: Context; exits: number[]; out: () => stri exit: code => void exits.push(code), ready: { onReady: (listener) => { listener(); return () => {} } }, }) - apply(ctx) + apply(ctx, { profile }) return { ctx, exits, out: () => out, stdin } } @@ -62,4 +62,10 @@ describe('SDK app startup', () => { stdin.end() expect(exits).toEqual([0]) }) + + it('renders the selected SDK profile name in help', () => { + const { out } = start(['--help'], 'sdk-minimal') + expect(out()).toContain('Usage: dsh --profile sdk-minimal') + expect(out()).toContain('dsh --profile sdk-minimal') + }) }) diff --git a/packages/bundle/sdk-app/tsconfig.json b/packages/bundle/sdk-app/tsconfig.json index 1d644141bd..0a98d3117c 100644 --- a/packages/bundle/sdk-app/tsconfig.json +++ b/packages/bundle/sdk-app/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../runtime-diagnostics/invariants" }, diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml new file mode 100644 index 0000000000..51cf2a832d --- /dev/null +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md +README.md: 5bc60991624ab52b3d55acac4851b6af2b198218 +README.zh.md: 7e11140bcb8a81cf3e22db88530bfcb364436f96 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md new file mode 100644 index 0000000000..5bc6099162 --- /dev/null +++ b/packages/bundle/sdk-minimal/README.md @@ -0,0 +1,31 @@ +# `@deepseek-ai/dsh-sdk-minimal` + +English | [中文](README.zh.md) + +Standalone minimal SDK application bundle for `dsh --profile sdk-minimal`. Its single insert is the complete Cordis tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a persistent Bash PTY, the string-replace editor, and uncompressed JSONL session persistence under `$DSH_HOME/sessions`. It deliberately does not include [`dsh-base`](../base/README.md), Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs tools, subagents, or any other model-facing tool. + +The profile remains part of the ordinary launcher and layering model. The bundle supplies the complete default tree; the profile patch, home patch, and ordered `--patch` files can replace rows or insert external bundles above it. `dsh plugin --profile sdk-minimal` manages persistent dependencies. The shipped template uses startup-only patches so one stdio connection never observes replacement of its server or agent dependencies. + +`DEEPSEEK_API_KEY` supplies the adapter credential. `DSH_MODEL` selects the sole configured model, `DSH_CONTEXT_WINDOW` sets its context window, and `DSH_SYSTEM_PROMPT` replaces the default persona. The process working directory is the sandbox-policy workspace and local-filesystem root. The bundle sets `danger-full-access`; its persistent shell and editor can modify any path available to the process. + +## Model Experience + +### Minimal coding-agent composition + +#### What the model sees + +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` and `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. + +#### Token effect + +One stable persona plus the two tool schemas. Tool results and ordinary conversation history grow with the session. + +#### KV Cache effect + +Stable for a fixed persona, platform, provider, model, and bundle patch stack. Profile changes take effect on the next process. + +## Known Limitations and Deferred Work + +- **The profile is POSIX-only** — this layer uses a Bash PTY; Windows support belongs to the platform runtime layer above it. +- **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. +- **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md new file mode 100644 index 0000000000..7e11140bcb --- /dev/null +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -0,0 +1,31 @@ +# `@deepseek-ai/dsh-sdk-minimal` + +[English](README.md) | 中文 + +供 `dsh --profile sdk-minimal` 使用的独立极简 SDK 应用组合包。它的单个 insert 构成完整 Cordis 树:SDK stdio 启动与 JSON-RPC 对外服务、一个由环境配置的 DeepSeek 适配器、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话持久化。它刻意不包含 [`dsh-base`](../base/README.zh.md)、Web、settings、托管凭据、遥测、压缩(compaction)、workspace 指令、skills、jobs 工具、subagent 或任何其他面向模型的工具。 + +该 profile 仍遵循普通 launcher 与分层模型。组合包提供完整默认树;profile patch、home patch 与有序 `--patch` 文件可以在其上替换配置项或插入外部组合包。`dsh plugin --profile sdk-minimal` 管理持久依赖。随附模板仅在启动时应用 patch,因此一个 stdio 连接不会观察到服务器或 agent 依赖在运行中被替换。 + +`DEEPSEEK_API_KEY` 提供适配器凭据。`DSH_MODEL` 选择唯一配置的模型,`DSH_CONTEXT_WINDOW` 设置其上下文窗口,`DSH_SYSTEM_PROMPT` 替换默认 persona。进程工作目录同时作为沙箱策略 workspace 与本地文件系统根目录。该组合包设置 `danger-full-access`;其持久 shell 与编辑器可以修改进程可访问的任何路径。 + +## 模型体验 + +### 极简 coding agent 组合 + +#### 模型看到的内容 + +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 agent 所有的持久 `bash` 与 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 + +#### Token 影响 + +一个稳定 persona 加两个工具 schema。工具结果与普通对话历史随会话增长。 + +#### KV Cache 影响 + +当 persona、平台、提供方、模型与组合包 patch 栈固定时保持稳定。Profile 变更在下一个进程生效。 + +## 已知限制与待办工作 + +- **该 profile 仅支持 POSIX** — 此层使用 Bash PTY;Windows 支持属于其上的平台运行时层。 +- **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、权限策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 +- **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml new file mode 100644 index 0000000000..e43f3e2b03 --- /dev/null +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -0,0 +1,97 @@ +# Standalone minimal SDK application. Unlike the ordinary SDK profile, this +# bundle does not layer over dsh-base: this insert is the complete Cordis tree. +# User profile, home, and invocation patches still apply above it. + +- insert: + - id: sdk-app-startup + name: '@deepseek-ai/dsh-sdk-app' + config: + profile: sdk-minimal + + - id: sdk-jsonrpc-server + name: '@deepseek-ai/dsh-sdk-jsonrpc-server' + inject: [sdkAppStartup, loader] + config: + maxTokensAsSuccess: false + + - id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + + - id: session-log-deepseek + name: '@deepseek-ai/dsh-session-log-deepseek' + + - id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKeyEnv: DEEPSEEK_API_KEY + streamIdleTimeoutMs: 172800000 + models: + - id: !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash' + contextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) + + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + + - id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + + - id: pty + name: '@deepseek-ai/dsh-terminal' + + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + config: + timeoutMs: 300000 + + # The editor uses the bare local filesystem; persistent Bash still consumes + # the shared danger-full-access sandbox policy above. + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + + - id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + includeRuntimeContext: false + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + + - id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + + - id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: none diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json new file mode 100644 index 0000000000..1b3d5da5d2 --- /dev/null +++ b/packages/bundle/sdk-minimal/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-sdk-minimal", + "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bundle/sdk-minimal" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-sdk-app": "workspace:^", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/bundle/sdk-minimal/src/index.ts b/packages/bundle/sdk-minimal/src/index.ts new file mode 100644 index 0000000000..a5f162161e --- /dev/null +++ b/packages/bundle/sdk-minimal/src/index.ts @@ -0,0 +1,9 @@ +/** + * @deepseek-ai/dsh-sdk-minimal — the standalone minimal SDK profile bundle. + * The package's substance is `cordis.patch.yml`, declared by the + * `dsh.bundle.patch` manifest field and resolved by the profile composer; + * this module carries no runtime interface. + * @module @deepseek-ai/dsh-sdk-minimal + */ + +export {} diff --git a/packages/bundle/sdk-minimal/src/invariant.ts b/packages/bundle/sdk-minimal/src/invariant.ts new file mode 100644 index 0000000000..e2f480504a --- /dev/null +++ b/packages/bundle/sdk-minimal/src/invariant.ts @@ -0,0 +1,26 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-minimal`. + * @module @deepseek-ai/dsh-sdk-minimal/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-minimal' + +/** Cordis companion plugin name. */ +export const name = 'sdk-minimal-bundle-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +// No runtime invariant: the package is a static patch-list carrier whose +// inserted rows own their runtime relationships and invariant companions. +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts new file mode 100644 index 0000000000..4e9fbbcc6a --- /dev/null +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -0,0 +1,59 @@ +/** The standalone SDK-minimal bundle's complete declared Cordis tree. */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' + +describe('dsh-sdk-minimal bundle', () => { + it('declares one standalone allowlisted tree with every row dependency', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + const patches = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) as Array<{ insert?: Array<{ id?: string; inject?: string[]; name?: string; config?: Record }> }> + expect(patches).toHaveLength(1) + const rows = patches[0]?.insert ?? [] + expect(rows.map(row => [row.id, row.name])).toEqual([ + ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'], + ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'], + ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'], + ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'], + ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'], + ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'], + ['sandbox', '@deepseek-ai/dsh-sandbox-local'], + ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], + ['subprocess', '@deepseek-ai/dsh-subprocess-local'], + ['pty', '@deepseek-ai/dsh-terminal'], + ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['fs-local', '@deepseek-ai/dsh-fs-local'], + ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], + ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], + ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ]) + expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' }) + expect(rows.find(row => row.id === 'sdk-jsonrpc-server')).toMatchObject({ + inject: ['sdkAppStartup', 'loader'], + config: { maxTokensAsSuccess: false }, + }) + expect(rows.find(row => row.id === 'agent-spine')?.config).toMatchObject({ + includeHarnessIdentity: false, + includeRuntimeContext: false, + workspaceContext: false, + skills: { enabled: false }, + toolBash: false, + toolJobs: false, + }) + expect(Object.keys(manifest.dependencies ?? {}).sort()).toEqual( + [...new Set(rows.map(row => row.name).filter((name): name is string => name !== undefined))].sort(), + ) + }) +}) diff --git a/packages/bundle/sdk-minimal/tsconfig.json b/packages/bundle/sdk-minimal/tsconfig.json new file mode 100644 index 0000000000..8f58ed6e28 --- /dev/null +++ b/packages/bundle/sdk-minimal/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daec43dbff..a6b3e12d45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: '@deepseek-ai/dsh-sdk-app': specifier: workspace:^ version: link:../../packages/bundle/sdk-app + '@deepseek-ai/dsh-sdk-minimal': + specifier: workspace:^ + version: link:../../packages/bundle/sdk-minimal '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../packages/session/session-projection @@ -1524,6 +1527,9 @@ importers: '@deepseek-ai/dsh-sdk-jsonrpc-server': specifier: workspace:^ version: link:../../sdk/server + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery commander: specifier: ^15.0.0 version: 15.0.0 @@ -1538,6 +1544,64 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/bundle/sdk-minimal: + dependencies: + '@deepseek-ai/dsh-agent-spine-demo': + specifier: workspace:^ + version: link:../../examples/agent-spine-demo + '@deepseek-ai/dsh-deepseek-llm-api-extensions': + specifier: workspace:^ + version: link:../../llm/deepseek-llm-api-extensions + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-plugin-package-inventory-deepseek': + specifier: workspace:^ + version: link:../../llm/plugin-package-inventory-deepseek + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-sdk-app': + specifier: workspace:^ + version: link:../sdk-app + '@deepseek-ai/dsh-sdk-jsonrpc-server': + specifier: workspace:^ + version: link:../../sdk/server + '@deepseek-ai/dsh-session-log-deepseek': + specifier: workspace:^ + version: link:../../session/session-log-deepseek + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:^ + version: link:../../shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../fs/tool-str-replace-editor + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/bundle/web-app: dependencies: '@deepseek-ai/dsh-agent-presets': diff --git a/tsconfig.host.json b/tsconfig.host.json index 91fbfd03f7..0bcbe50df3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -263,6 +263,7 @@ { "path": "./packages/bundle/base" }, { "path": "./packages/bundle/headless" }, { "path": "./packages/bundle/sdk-app" }, + { "path": "./packages/bundle/sdk-minimal" }, { "path": "./packages/bundle/web-app" }, { "path": "./packages/boot/app-boot" }, { "path": "./packages/boot/cmdline" }, From 79a8f667f71ef5e09fda54fed5079fab93177ebc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:04:24 +0800 Subject: [PATCH 069/138] refactor(python): launch the minimal example through sdk-minimal Make minimal.py select the shipped sdk-minimal profile directly and pass its selected model into the profile-owned adapter catalog. The Python SDK still starts only the bundled dsh CLI with an explicit Harness home; it no longer supplies an invocation overlay for this mode. Drive both the source keyless process test and installed-wheel smoke through the same named profile. The keyless test pins the generated profile manifest and exact two-tool model request, while the packaged smoke keeps the persistent-shell, editor, session-log, and model-visible snapshot evidence. Delete minimal.patch.yml and the unused complete-config/replay fixtures. Their composition now has one owner in @deepseek-ai/dsh-sdk-minimal, so the example and tests cannot drift into separate launch trees. --- examples/python-sdk-agent/minimal.cordis.yml | 90 ------------------- examples/python-sdk-agent/minimal.patch.yml | 47 ---------- examples/python-sdk-agent/minimal.py | 7 +- .../minimal.snapshot.cordis.yml | 20 ----- .../tests/keyless-smoke.e2e.ts | 17 ++-- scripts/smoke-python-runtime.py | 78 ++-------------- 6 files changed, 18 insertions(+), 241 deletions(-) delete mode 100644 examples/python-sdk-agent/minimal.cordis.yml delete mode 100644 examples/python-sdk-agent/minimal.patch.yml delete mode 100644 examples/python-sdk-agent/minimal.snapshot.cordis.yml diff --git a/examples/python-sdk-agent/minimal.cordis.yml b/examples/python-sdk-agent/minimal.cordis.yml deleted file mode 100644 index f849b798a0..0000000000 --- a/examples/python-sdk-agent/minimal.cordis.yml +++ /dev/null @@ -1,90 +0,0 @@ -# Complete minimal-agent composition fixture for lower-level snapshot tests. -# Python users apply `minimal.patch.yml` to `dsh --profile sdk`. This fixture -# retains the same two-tool behavior without defining a supported launch path. - -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' - config: - maxTokensAsSuccess: false - -- id: deepseek-llm-api-extensions - name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' - -- id: session-log-deepseek - name: '@deepseek-ai/dsh-session-log-deepseek' - -- id: plugin-package-inventory-deepseek - name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKeyEnv: DEEPSEEK_API_KEY - streamIdleTimeoutMs: 172800000 - models: - - id: !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash' - contextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() - -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: pty - name: '@deepseek-ai/dsh-terminal' - -- id: terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - config: - timeoutMs: 300000 - -# The editor uses the bare local filesystem; persistent Bash still consumes the -# shared danger-full-access sandbox policy above. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - includeRuntimeContext: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolJobs: false - -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 - description: |- - Run commands in a bash shell - * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. - * You don't have access to the internet via this tool. - * You do have access to a mirror of common linux and python packages via apt and pip. - * State is persistent across command calls and discussions with the user. - * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. - * Please avoid commands that may produce a very large amount of output. - * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. - -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - compression: none diff --git a/examples/python-sdk-agent/minimal.patch.yml b/examples/python-sdk-agent/minimal.patch.yml deleted file mode 100644 index 2c3ac0f5bc..0000000000 --- a/examples/python-sdk-agent/minimal.patch.yml +++ /dev/null @@ -1,47 +0,0 @@ -# Minimal Python SDK overlay for `dsh --profile sdk`. The SDK server applies -# the model-facing allowlist to every root session, so later base-bundle tools -# cannot appear implicitly. The profile still owns JSON-RPC and persistence. - -- id: system-prompt - config: - includeHarnessIdentity: false - includeRuntimeContext: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - personaComplete: true - -- id: sdk-jsonrpc-server - config: - maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" - toolFilter: - allow: - - bash - - str_replace_editor - -# Remove non-tool model inputs and turn rewriting, plus the one-shot Bash row -# that would conflict with the persistent Bash tool inserted below. -- id: agent-instructions - disabled: true -- id: tool-bash - disabled: true -- id: compaction-basic - disabled: true -- id: command-compact - disabled: true -- id: tool-result-pruner - disabled: true - -- id: tool-str-replace-editor - config: - maxOutputChars: 16000 - -- insert: - - id: pty - name: '@deepseek-ai/dsh-terminal' - - id: terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - config: - timeoutMs: 300000 - - id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 diff --git a/examples/python-sdk-agent/minimal.py b/examples/python-sdk-agent/minimal.py index 255a0dd3c0..86c1dc8082 100644 --- a/examples/python-sdk-agent/minimal.py +++ b/examples/python-sdk-agent/minimal.py @@ -10,9 +10,6 @@ from pathlib import Path from deepseek_harness import DeepSeekHarness -PATCH = Path(__file__).with_name("minimal.patch.yml") - - def main() -> None: """Parse one task and print the agent's final response.""" parser = argparse.ArgumentParser() @@ -24,7 +21,7 @@ def main() -> None: type=Path, default=Path(configured_home) if configured_home.strip() else None, ) - parser.add_argument("--profile", default="sdk") + parser.add_argument("--profile", default="sdk-minimal") parser.add_argument("--session-id") parser.add_argument("--provider", default="deepseek-official") parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash")) @@ -42,7 +39,7 @@ def main() -> None: cwd=str(workspace), dsh_home=str(dsh_home), profile=args.profile, - patches=(str(PATCH.resolve()),), + env={"DSH_MODEL": args.model}, ) as harness: result = harness.run(args.prompt, session_id=args.session_id) print(result.final_response) diff --git a/examples/python-sdk-agent/minimal.snapshot.cordis.yml b/examples/python-sdk-agent/minimal.snapshot.cordis.yml deleted file mode 100644 index 0f26fa6716..0000000000 --- a/examples/python-sdk-agent/minimal.snapshot.cordis.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Keyless replay keeps the complete minimal composition intact and replaces -# only its live DeepSeek adapter with the fixture-backed provider. The replay -# catalog claims the same route initialized by the SDK. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./minimal.cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek-official - name: DeepSeek - models: - - id: deepseek-v4-flash diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 4b2519c7f8..e0bd4ed6c3 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -10,7 +10,6 @@ import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) -const minimalPatchPath = fileURLToPath(new URL('../minimal.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -181,7 +180,7 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) - it('keeps the minimal overlay on an explicit model-facing allowlist', async () => { + it('boots the standalone minimal profile with its exact model-facing roster', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { @@ -205,16 +204,13 @@ describe('Python SDK dsh profile keyless smoke', () => { 'tsx/esm', binScript, '--profile', - 'sdk', - '--patch', - minimalPatchPath, + 'sdk-minimal', ], { cwd: repoRoot, env: { DSH_HOME: join(root, '.dsh'), - DSH_PERMISSION_MODE: 'danger-full-access', + DSH_MODEL: 'deepseek-v4-pro', DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', - DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, }, @@ -259,6 +255,13 @@ describe('Python SDK dsh profile keyless smoke', () => { } expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual(['bash', 'str_replace_editor']) + const profile = JSON.parse( + await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'), + ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } } + expect(profile.dsh?.profile).toEqual({ + bundles: ['@deepseek-ai/dsh-sdk-minimal'], + patchReload: 'startup', + }) child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) await waitForLine(lines, value => value.id === 3, () => stderr) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 3be8a9bcfb..ba5279000d 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -46,14 +46,6 @@ MINIMAL_BASH_COMMAND = ( "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" ) -MINIMAL_BASH_DESCRIPTION = """Run commands in a bash shell -* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. -* You don't have access to the internet via this tool. -* You do have access to a mirror of common linux and python packages via apt and pip. -* State is persistent across command calls and discussions with the user. -* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. -* Please avoid commands that may produce a very large amount of output. -* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.""" LEGACY_CUSTOM_DISABLED_ROWS = ( "agent-instructions", "goal", @@ -121,10 +113,6 @@ RESTART_SNAPSHOT_DIRECTORY = ( Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart" ) RESTART_SNAPSHOT_FILENAMES = ("result.json", "requests.json", "session.1.jsonl", "session.2.jsonl") -# The agent loop's dynamic runtime-context snapshot is the one model-visible message this -# expected output cannot carry: the same composition emits it on macOS and not on Linux -# (deepseek-harness#2488), and the file must replay on both. Everything else is compared. -RUNTIME_CONTEXT_PREFIX = "Current runtime context" MCP_SERVER_SCRIPT = """\ import json import os @@ -972,7 +960,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) -> None: - """Exercise the checked-in minimal composition through the packaged executable.""" + """Exercise the shipped standalone minimal profile through the packaged executable.""" from deepseek_harness import DeepSeekHarness # One mock model serves every scenario of a run, so the snapshot takes this turn's slice. @@ -983,59 +971,15 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}" dsh_home = root / "home" sessions = dsh_home / "sessions" - disabled = [ - "agent-instructions", - "tool-bash", - "compaction-basic", - "command-compact", - "tool-result-pruner", - ] - patch = write_profile_patch(root, "minimal.patch.yml", sessions, [ - { - "id": "system-prompt", - "config": { - "includeHarnessIdentity": False, - "includeRuntimeContext": False, - "persona": "You are a helpful software engineer assistant.", - "personaComplete": True, - }, - }, - { - "id": "sdk-jsonrpc-server", - "config": { - "maxTokensAsSuccess": True, - "toolFilter": {"allow": ["bash", "str_replace_editor"]}, - }, - }, - *({"id": row_id, "disabled": True} for row_id in disabled), - {"id": "tool-str-replace-editor", "config": {"maxOutputChars": 16000}}, - {"insert": [ - {"id": "pty", "name": "@deepseek-ai/dsh-terminal"}, - { - "id": "terminal-bash", - "name": "@deepseek-ai/dsh-terminal-bash", - "config": {"timeoutMs": 300000}, - }, - { - "id": "persistent-bash", - "name": "@deepseek-ai/dsh-tool-bash-persistent", - "config": { - "timeoutMs": 300000, - "description": MINIMAL_BASH_DESCRIPTION, - }, - }, - ]}, - ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), dsh_bin=str(executable), dsh_home=str(dsh_home), - patches=(str(patch),), + profile="sdk-minimal", env={ - "DSH_PERMISSION_MODE": "danger-full-access", - "DSH_TELEMETRY_DISABLED": "1", + "DSH_MODEL": "smoke-model", }, api_key="sk-keyless-smoke", base_url=base_url, @@ -1550,9 +1494,9 @@ def build_minimal_snapshot_files( Every assembled system prompt, advertised tool schema, and system or user message is kept verbatim: they carry what the deployment actually shows the model, so a plugin that contributes an unintended system section or user message cannot pass unnoticed. - Assistant and tool payloads keep only their call identity, and the dynamic - runtime-context snapshot is dropped, because their text differs across the platforms - this expected output must replay on. + Assistant and tool payloads keep only their call identity because their text differs + across the platforms this expected output must replay on. The shipped profile omits + dynamic runtime context, so every message it emits is compared. """ snapshot = [] for body in requests: @@ -1564,21 +1508,11 @@ def build_minimal_snapshot_files( "messages": [ minimal_snapshot_message(message, cwd) for message in messages - if not is_runtime_context_message(message) ], }) return {"model-visible.json": json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n"} -def is_runtime_context_message(message: object) -> bool: - """Identify the agent loop's dynamic runtime-context snapshot, current or cleared.""" - return ( - isinstance(message, dict) - and message.get("role") == "user" - and message_text(message.get("content")).startswith(RUNTIME_CONTEXT_PREFIX) - ) - - def minimal_snapshot_message(message: object, cwd: Path) -> dict[str, object]: """Reduce one model-visible message to its stable, behavior-carrying parts.""" if not isinstance(message, dict): From 7a11f5fde3ad63994a3180dad2c6372f0723562f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:39:43 +0800 Subject: [PATCH 070/138] docs(python): define the standalone minimal profile Record sdk-minimal as the narrow repository-owned exception to base-first profile composition: callers still launch only dsh and cannot provide an arbitrary Cordis tree, while the shipped bundle may own a complete explicit roster. Cross-link the launcher, profile-bundle, Python-runtime, minimal-agent, snapshot, and telemetry decisions; the supersession audit keeps each older note active because its remaining rationale is independent. Update the CLI, architecture, Python tutorial/reference, example, runtime-wheel reference, and bundle documentation. The docs distinguish the full sdk profile from sdk-minimal, explain explicit-home/plugin/patch customization, state the minimal permission and persistence choices, and retain the separately packaged web profile and frontend assets for direct dsh use. Correct dsh-base descriptions to cover base-backed profiles, make SDK startup configuration visible in the generated config catalog, add sdk-minimal to the module graph, and regenerate the base-composition graph. English and Chinese pairs are re-recorded at the exact reviewed contents. --- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 6 +- .../2026-08-05-profile-plugin-bundles.zh.md | 6 +- ...-single-dsh-application-launcher.i18n.yaml | 4 +- ...6-08-22-single-dsh-application-launcher.md | 17 +++--- ...8-22-single-dsh-application-launcher.zh.md | 19 ++++--- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 10 ++-- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 10 ++-- ...4-standalone-sdk-minimal-profile.i18n.yaml | 6 ++ ...26-08-24-standalone-sdk-minimal-profile.md | 57 +++++++++++++++++++ ...08-24-standalone-sdk-minimal-profile.zh.md | 57 +++++++++++++++++++ ...nimal-preset-owns-rl-composition.i18n.yaml | 4 +- ...8-10-minimal-preset-owns-rl-composition.md | 4 +- ...0-minimal-preset-owns-rl-composition.zh.md | 6 +- ...7-31-web-telemetry-default-mount.i18n.yaml | 4 +- .../2026-07-31-web-telemetry-default-mount.md | 4 +- ...26-07-31-web-telemetry-default-mount.zh.md | 4 +- ...l-profiles-bare-two-tool-runtime.i18n.yaml | 4 +- ...-minimal-profiles-bare-two-tool-runtime.md | 10 ++-- ...nimal-profiles-bare-two-tool-runtime.zh.md | 10 ++-- ...n-minimal-model-visible-snapshot.i18n.yaml | 4 +- ...3-python-minimal-model-visible-snapshot.md | 12 ++-- ...ython-minimal-model-visible-snapshot.zh.md | 12 ++-- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 7 ++- apps/cli/README.zh.md | 7 ++- apps/cli/composition.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 9 +-- apps/cli/reference/README.zh.md | 9 +-- apps/cli/src/profile-boot.ts | 4 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 10 ++-- docs/architecture.zh.md | 10 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 18 +++++- docs/config-catalog.zh.md | 18 +++++- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 30 +++++----- docs/user/guide/python-sdk.zh.md | 30 +++++----- examples/python-sdk-agent/README.i18n.yaml | 4 +- examples/python-sdk-agent/README.md | 14 +++-- examples/python-sdk-agent/README.zh.md | 14 +++-- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/bundle/README.i18n.yaml | 4 +- packages/bundle/README.md | 2 +- packages/bundle/README.zh.md | 2 +- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/sdk-minimal/README.i18n.yaml | 4 +- packages/bundle/sdk-minimal/README.md | 2 +- packages/bundle/sdk-minimal/README.zh.md | 2 +- packages/examples/README.i18n.yaml | 4 +- packages/examples/README.md | 2 +- packages/examples/README.zh.md | 2 +- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/development.i18n.yaml | 4 +- python/development.md | 2 +- python/development.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 4 +- python/sdk/README.zh.md | 4 +- scripts/gen-doc-graphs.ts | 2 +- 77 files changed, 372 insertions(+), 204 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md create mode 100644 .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index f43fae952f..a70ebb7db2 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: c9f685eecddcd4ea8d8580becef3c32a9693a329 -2026-08-05-profile-plugin-bundles.zh.md: 3f2db5ea7653fde356f6ae8f21898f1fdada3926 +2026-08-05-profile-plugin-bundles.md: 493568691dac3a54185f11cbbf8162bf6b6355b1 +2026-08-05-profile-plugin-bundles.zh.md: adfa95b6f8d0fdd6fe3c0ebbc7a62d935ebb1987 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index c9f685eecd..493568691d 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The default Profile templates use `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. +The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](2026-08-24-standalone-sdk-minimal-profile.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). @@ -27,7 +27,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th ## Consequences -- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile; the repository no longer needs a row for every deployment shape. +- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile, without a repository row for every deployment shape. - `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone. - The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly. -- Backends reject nothing old on disk (pre-release stance): `$DSH_HOME/config.yaml` is simply no longer read. +- Under the pre-release stance, backends carry no compatibility behavior for old on-disk configuration; `$DSH_HOME/config.yaml` is ignored. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 3f2db5ea76..adfa95b6f8 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.zh.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -默认 Profile 模板使用的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.zh.md)负责 headless 组合约定。 +默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.zh.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 @@ -27,7 +27,7 @@ Status: implemented ## Consequences -- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装;仓库不再需要为每种部署形态各留一行。 +- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装,无需在仓库中为每种部署形态各留一行。 - `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。 - 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会响亮失败。 -- 后端不拒绝磁盘上的任何旧格式(发布前姿态):`$DSH_HOME/config.yaml` 只是不再被读取。 +- 按发布前姿态,后端不携带旧磁盘配置的兼容行为;`$DSH_HOME/config.yaml` 会被忽略。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index de2009b1ce..61028c878a 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 27ec766dc62a203a3054ec67828b8897ab5ad245 -2026-08-22-single-dsh-application-launcher.zh.md: 5fb7fb4234b093a6f69446e0d9b1d7fbb7f46cf3 +2026-08-22-single-dsh-application-launcher.md: 48a45cb2454b5532a78474203b6d88aef3dd0697 +2026-08-22-single-dsh-application-launcher.zh.md: dbf2fb3a5bdc16208b0435482d8d0851bd7c44d7 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 27ec766dc6..48a45cb245 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -14,13 +14,13 @@ The Python SDK distributes a native executable and three platform wheels. Its pa ### Launch scope -Every supported Node application starts through the `dsh` CLI and one named profile. The shipped application commands are `dsh web`, `dsh --profile headless`, `dsh --profile sdk`, and `dsh --profile acp`; `dsh web` is the deliberate convenience alias for `--profile web`, not another application entry. +Every supported Node application starts through the `dsh` CLI and one named profile. The shipped application commands are `dsh web`, `dsh --profile headless`, `dsh --profile sdk`, `dsh --profile sdk-minimal`, and `dsh --profile acp`; `dsh web` is the deliberate convenience alias for `--profile web`, not another application entry. Vendor CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are outside the application-launch inventory. A package app bin or root demo that launches a package entry is not an accepted extension point. ### Profile applications -`@deepseek-ai/dsh-sdk-app` and `@deepseek-ai/dsh-acp-app` compose the protocol applications over `@deepseek-ai/dsh-base`. The SDK bundle adds the JSON-RPC server plus app-owned help and stdio lifetime; the ACP bundle adds the automation-only ACP server plus the same application responsibilities. Both adopt the base model, tools, persistence, settings, credentials, policy, and environment behavior. +`@deepseek-ai/dsh-sdk-app` and `@deepseek-ai/dsh-acp-app` compose the full protocol applications over `@deepseek-ai/dsh-base`. The SDK bundle adds the JSON-RPC server plus app-owned help and stdio lifetime; the ACP bundle adds the automation-only ACP server plus the same application responsibilities. Both adopt the base model, tools, persistence, settings, credentials, policy, and environment behavior. The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) reuses SDK startup and JSON-RPC serving but deliberately owns a complete explicit tree without `dsh-base`. Profile manifests own patch reload: @@ -29,11 +29,12 @@ Profile manifests own patch reload: | `web` | `live` | | `headless` | `startup` | | `sdk` | `startup` | +| `sdk-minimal` | `startup` | | `acp` | `startup` | Custom profiles default to `live`. A startup profile still applies its bundle, profile, home-level, and invocation `--patch` layers, but it does not watch them after boot. `dsh-base` inserts the module-HMR row disabled; a profile with a tested source-module reload lifecycle must enable it explicitly. None of the shipped profiles enable server module HMR: `patchReload: live` uses the launcher's config-only watcher while the startup profiles install no watcher. SDK and ACP cannot safely replace their server, agents, persistence, or tool registry inside one owned stdio connection. -The shipped protocol profiles reserve stdout for protocol frames, expose help without starting transport, and route stdin EOF and signals through bounded root disposal. ACP remains automation-only. The SDK JSON-RPC methods, notification fields, and `initialize.serverInfo.name` remain stable. Model-visible tool and persistence defaults come from `dsh-base`, and runnable snapshots own those assembled application outputs. +The shipped protocol profiles reserve stdout for protocol frames, expose help without starting transport, and route stdin EOF and signals through bounded root disposal. ACP remains automation-only. The SDK JSON-RPC methods, notification fields, and `initialize.serverInfo.name` remain stable. Full-profile model-visible tool and persistence defaults come from `dsh-base`; `sdk-minimal` owns its explicit defaults. Runnable snapshots own the assembled application outputs. ### TypeScript SDK customization @@ -45,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher ### Python runtime -The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk`, ordered patch files, and an explicit Harness home; the installed `dsh` console command exposes the same profile grammar. The runnable Python example is `examples/python-sdk-agent`. +The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable Python example selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. @@ -57,7 +58,7 @@ The executable family is `deepseek-harness-sdk-runtime--`. The S This decision supersedes the application-launch and package-name facts in [profile plugin bundles](2026-08-05-profile-plugin-bundles.md), [TypeScript SDK client and subagent backend](../feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md), [remove the SDK project toolchain](../simplification/2026-08-11-remove-sdk-project-toolchain.md), and [single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md). Those notes retain independent authority for profile layering, client/wire semantics, deleted project tooling, and native packaging. -The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) remains authoritative for ACP wire and interaction scope. The [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) remains authoritative for role-based package names. No active note is fully superseded or eligible for archival. +The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) remains authoritative for ACP wire and interaction scope. The [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) remains authoritative for role-based package names. The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) partially supersedes this note's base-first rule and complete-tree alternative while retaining this note's launcher ownership. No active note is fully superseded or eligible for archival. ## Alternatives considered @@ -65,7 +66,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o **Keep forwarding compatibility bins.** Rejected: a forwarding executable remains another public launch name and compatibility promise. The pre-release repository can move callers directly to profiles. -**Put complete standalone Cordis trees behind profile wrappers.** Rejected: that centralizes argv without centralizing application composition. `dsh-base` plus thin app bundles gives shared policy one owner while retaining protocol-specific negative guarantees. +**Put caller-supplied complete Cordis trees behind profile wrappers.** Rejected: that centralizes argv without centralizing application composition. Full profiles use `dsh-base` plus thin app bundles so shared policy has one owner. A repository-owned, versioned standalone bundle is allowed only when an explicit roster is the product behavior, as [sdk-minimal](2026-08-24-standalone-sdk-minimal-profile.md) records. **Accept inline plugins or a complete `cordis.yml` in the TypeScript constructor.** Rejected: the SDK would become another package installer and application composer. Named profiles and patch files already provide persistent and per-launch customization through one resolution model. @@ -79,7 +80,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o ## Verification -- Source and built CLI acceptance cover `sdk` and `acp` help, transport startup, stdout purity, EOF, signals, and root disposal. +- Source and built CLI acceptance cover `sdk`, `sdk-minimal`, and `acp` help, transport startup, stdout purity, EOF, signals, and root disposal. - Bundle configuration tests pin module HMR disabled in `dsh-base` and absent from shipped mode overrides; the custom live-profile e2e pins config reload through the launcher's watch-only fallback. - Focused unit suites cover profile launch resolution, initialization bounds, SDK retries, server readiness, and nested isolated homes with 100% coverage on the changed runtime sources. - Keyless ACP and SDK snapshots boot real `dsh` profiles and pin protocol output plus persisted logs; the nested SDK composition boots a second real profile runtime. @@ -91,7 +92,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o - A user changes an SDK application's plugin composition through a named profile and ordered patches, using the same installation and resolution model as every other dsh application. - A custom profile receives live config watching without server module HMR and opts into source-module replacement only through an explicit row override. -- SDK and ACP share the complete base application and one set of policy and tools; snapshots present intentional assembled differences explicitly. +- The full SDK and ACP profiles share the complete base application and one set of policy and tools; `sdk-minimal` owns its explicit standalone roster, and snapshots present intentional assembled differences. - Adding `@deepseek-ai/dsh` increases the TypeScript client's install size in exchange for a deterministic same-version runtime. - Trusted user patches can add a plugin that writes to stdout and corrupt their own protocol stream; shipped profiles guarantee purity, not arbitrary third-party composition. - Python packages the ordinary `dsh` profile launcher while retaining a closed native runtime and no system-Node requirement for wheel users. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index 5fb7fb4234..dbf2fb3a5b 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -14,13 +14,13 @@ Python SDK 分发一个原生可执行文件和三个平台 wheel 包。其打 ### 启动范围 -所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附应用命令是 `dsh web`、`dsh --profile headless`、`dsh --profile sdk` 与 `dsh --profile acp`;`dsh web` 是刻意为 `--profile web` 保留的便捷别名,不是另一个应用入口。 +所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附应用命令是 `dsh web`、`dsh --profile headless`、`dsh --profile sdk`、`dsh --profile sdk-minimal` 与 `dsh --profile acp`;`dsh web` 是刻意为 `--profile web` 保留的便捷别名,不是另一个应用入口。 Vendor CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于应用启动清单。包应用 bin 或直接启动包入口的根 demo 都不是可接受的扩展点。 ### Profile 应用 -`@deepseek-ai/dsh-sdk-app` 与 `@deepseek-ai/dsh-acp-app` 在 `@deepseek-ai/dsh-base` 之上组合协议应用。SDK 组合包增加 JSON-RPC 服务器、应用自有帮助和 stdio 生命周期;ACP 组合包增加仅用于自动化的 ACP 服务器与相同的应用职责。两者都采用 base 层的模型、工具、持久化、settings、credentials、策略和环境行为。 +`@deepseek-ai/dsh-sdk-app` 与 `@deepseek-ai/dsh-acp-app` 在 `@deepseek-ai/dsh-base` 之上组合完整协议应用。SDK 组合包增加 JSON-RPC 服务器、应用自有帮助和 stdio 生命周期;ACP 组合包增加仅用于自动化的 ACP 服务器与相同的应用职责。两者都采用 base 层的模型、工具、持久化、settings、credentials、策略和环境行为。[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)复用 SDK 启动与 JSON-RPC 服务,但刻意拥有不含 `dsh-base` 的完整显式配置树。 Profile manifest 负责 patch 重载: @@ -29,11 +29,12 @@ Profile manifest 负责 patch 重载: | `web` | `live` | | `headless` | `startup` | | `sdk` | `startup` | +| `sdk-minimal` | `startup` | | `acp` | `startup` | 自定义 profile 默认为 `live`。`startup` profile 仍会应用组合包、profile、home 级与调用时 `--patch` 各层,但启动后不会监视这些文件。`dsh-base` 插入的模块 HMR(热模块替换)配置项默认禁用;具有经过验证的源码模块重载生命周期的 profile 必须显式启用它。随附 profile 均不启用服务器模块 HMR:`patchReload: live` 使用启动器的仅配置 watcher,`startup` profile 则不安装 watcher。SDK 与 ACP 无法在一个自有 stdio 连接内安全替换其服务器、agent、持久化或工具注册表。 -随附协议 profile 将 stdout 保留给协议帧,显示帮助时不启动 transport,并通过有界根节点 dispose(资源释放)处理 stdin EOF 与信号。ACP 继续仅用于自动化。SDK JSON-RPC 方法、通知字段与 `initialize.serverInfo.name` 保持稳定。模型可见工具与持久化默认值来自 `dsh-base`,可运行快照负责钉住这些已组装的应用输出。 +随附协议 profile 将 stdout 保留给协议帧,显示帮助时不启动 transport,并通过有界根节点 dispose(资源释放)处理 stdin EOF 与信号。ACP 继续仅用于自动化。SDK JSON-RPC 方法、通知字段与 `initialize.serverInfo.name` 保持稳定。完整 profile 的模型可见工具与持久化默认值来自 `dsh-base`;`sdk-minimal` 拥有自己的显式默认值。可运行快照负责固定已组装的应用输出。 ### TypeScript SDK 自定义 @@ -45,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 ### Python 运行时 -Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端选择 `dsh --profile sdk`、有序 patch 文件和显式 Harness home;安装的 `dsh` 控制台命令暴露相同 profile 语法。可运行的 Python 示例是 `examples/python-sdk-agent`。 +Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;可运行 Python 示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 @@ -57,7 +58,7 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest 本决策取代 [profile 插件组合包](2026-08-05-profile-plugin-bundles.zh.md)、[TypeScript SDK 客户端与 SDK subagent 后端](../feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)、[移除 SDK 项目工具链](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)和[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的应用启动与包名事实。这些 Note 对 profile 分层、客户端/协议语义、已删除的项目工具链与原生打包仍分别具有独立权威。 -[ACP 仅自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)继续负责 ACP 协议格式与交互范围。[仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)继续负责基于角色的包名。没有任何活跃 Note 被完全取代,也没有 Note 符合归档条件。 +[ACP 仅自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)继续负责 ACP 协议格式与交互范围。[仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)继续负责基于角色的包名。[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)部分取代本 Note 的 base 优先规则与完整配置树替代方案,同时保留本 Note 对 launcher 所有权的决策。没有任何活跃 Note 被完全取代,也没有 Note 符合归档条件。 ## 考虑过的替代方案 @@ -65,7 +66,7 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest **保留转发兼容 bin。** 拒绝:转发可执行文件仍然形成另一个公开启动名称与兼容承诺。预发布仓库可以让调用方直接迁移到 profile。 -**把完整独立 Cordis 树放到 profile wrapper 后面。** 拒绝:这只集中 argv,没有集中应用组合。`dsh-base` 加轻量应用组合包让共享策略只有一个归属,同时保留协议专属的负面保证。 +**把调用方提供的完整 Cordis 树放到 profile wrapper 后面。** 拒绝:这只集中 argv,没有集中应用组合。完整 profile 使用 `dsh-base` 加轻量应用组合包,使共享策略只有一个归属。只有当显式清单本身属于产品行为时,才允许仓库自有且有版本的独立组合包,具体见 [sdk-minimal](2026-08-24-standalone-sdk-minimal-profile.zh.md)。 **在 TypeScript 构造函数中接受内联插件或完整 `cordis.yml`。** 拒绝:SDK 会因此成为另一个包安装器和应用组合器。具名 profile 与 patch 文件已通过统一解析模型提供持久与逐次启动自定义。 @@ -79,8 +80,8 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest ## 验证 -- 源码与构建后 CLI 验收覆盖 `sdk` 和 `acp` 的帮助、transport 启动、stdout 纯净性、EOF、信号与根节点 dispose。 -- 组合包配置测试钉住 `dsh-base` 默认禁用模块 HMR,随附模式覆盖层不再重复该策略;自定义 live profile 的 e2e 钉住启动器仅监视 fallback 提供的配置重载。 +- 源码与构建后 CLI 验收覆盖 `sdk`、`sdk-minimal` 和 `acp` 的帮助、transport 启动、stdout 纯净性、EOF、信号与根节点 dispose。 +- 组合包配置测试钉住 `dsh-base` 默认禁用模块 HMR,随附模式覆盖层不含该策略;自定义 live profile 的 e2e 钉住启动器仅监视 fallback 提供的配置重载。 - 聚焦单元套件覆盖 profile 启动解析、初始化时限、SDK 重试、服务器就绪和嵌套隔离 home,并对变更后的运行时源码实现 100% 覆盖率。 - 免密钥 ACP 与 SDK 快照启动真实 `dsh` profile,并钉住协议输出与持久化日志;嵌套 SDK 组合会启动第二个真实 profile 运行时。 - 真实 API 工作流把文件并行度限制为 4,因为一个 profile e2e 文件可能拥有多个完整 `dsh` 子进程树;工作流测试会钉住该资源上限。 @@ -91,7 +92,7 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest - 用户通过具名 profile 与有序 patch 更改 SDK 应用的插件组合,使用与其他所有 dsh 应用相同的安装与解析模型。 - 自定义 profile 可以在不启用服务器模块 HMR 的情况下获得实时配置监视,只有显式覆盖配置项才会启用源码模块替换。 -- SDK 与 ACP 共享完整 base 应用和同一份策略与工具;快照以显式差异呈现刻意采用的组装变化。 +- 完整 SDK 与 ACP profile 共享完整 base 应用和同一份策略与工具;`sdk-minimal` 拥有自己的显式独立清单,快照会呈现这些刻意采用的组装差异。 - 增加 `@deepseek-ai/dsh` 会扩大 TypeScript 客户端的安装体积,换来确定的同版本运行时。 - 受信任用户 patch 可以增加写入 stdout 的插件并破坏自己的协议流;随附 profile 保证纯净,不为任意第三方组合提供保证。 - Python 打包普通 `dsh` profile 启动器,同时保留封闭原生运行时,wheel 用户无需系统 Node。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 8cc79728eb..f418c62b83 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: e3df2d01e3aef7e6eadaa011d51c9ab87456d35f -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 3d50cac36d469172e91be450cea658f3a4830ccf +2026-08-23-python-sdk-dsh-profile-runtime.md: 3298224688e8f0cd4216f8ed68d9e4364014d2a9 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 400e70113e1112aa6e4979c64a5046199c07e8a0 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index e3df2d01e3..3298224688 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -14,7 +14,7 @@ A normal profile cannot be adopted only at the Python wrapper. The runtime execu ### One application launcher -The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path. +The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The runnable minimal example selects the shipped `sdk-minimal` profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path. The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, a bounded initialization timeout, and optional turn/shutdown timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path. @@ -22,9 +22,9 @@ Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME ### Plugin customization -Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. +Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile ...` manages external dependencies and bundle order, `$DSH_HOME/profiles//cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A selected profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. -The checked-in minimal overlay retains the shared SDK profile while configuring the server with a root-agent tool allow list. Later base-bundle tools remain unavailable unless the overlay names them. Its deployment persona is a complete system prompt, so unrelated guidance sections cannot describe hidden tools; dynamic runtime context, workspace instructions, compaction, and the conflicting one-shot Bash row are disabled separately. +The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) lists one repository-owned bundle that inserts its complete explicit tree without `dsh-base`. Its persistent Bash and string-replace editor are present by composition rather than a server filter; dynamic runtime context, workspace instructions, settings, managed credentials, telemetry, compaction, and every other base row are absent. The same runtime still packages the full `sdk` and `web` profiles as separate choices. The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. @@ -38,7 +38,7 @@ The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-w ## Existing decisions and supersession -This decision implements and supersedes the Python exception and deferred-migration sections of [the single dsh application launcher](2026-08-22-single-dsh-application-launcher.md). It supersedes the private application, external complete-config, artifact-name, and customization facts in [the single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md), which remains authoritative for pkg/SEA, wheel construction, native target validation, and publication. No active note is fully superseded, so none is archived. +This decision implements and supersedes the Python exception and deferred-migration sections of [the single dsh application launcher](2026-08-22-single-dsh-application-launcher.md). It supersedes the private application, external complete-config, artifact-name, and customization facts in [the single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md), which remains authoritative for pkg/SEA, wheel construction, native target validation, and publication. The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) supersedes only this note's minimal-overlay realization. No active note is fully superseded, so none is archived. ## Alternatives considered @@ -52,4 +52,4 @@ This decision implements and supersedes the Python exception and deferred-migrat ## Consequences -Python callers configure the same profile vocabulary as TypeScript and direct CLI users, and arbitrary external bundles can extend an SDK profile without replacing the application tree. Homes must now be selected explicitly, complete-config and `session_root` parameters are unavailable, and the executable includes shared-library assets plus profile-module proxies. The stronger installed-wheel CI makes those package, profile, native, and provider paths release requirements rather than source-only assumptions. +Python callers configure the same profile vocabulary as TypeScript and direct CLI users, and arbitrary external bundles can extend an SDK profile without introducing another launcher. Homes are selected explicitly, complete-config and `session_root` parameters are unavailable, and the executable includes shared-library assets plus profile-module proxies. The full `sdk`, standalone `sdk-minimal`, and `web` applications remain separate profiles inside the same packaged CLI. The installed-wheel CI makes those package, profile, native, and provider paths release requirements rather than source-only assumptions. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 3d50cac36d..400e70113e 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -14,7 +14,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ### 一个应用启动器 -运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。 +运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。可运行极简示例选择随附 `sdk-minimal` profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。 公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择、有界初始化 timeout,以及可选的轮次/关闭 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。 @@ -22,9 +22,9 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ### 插件自定义 -持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 +持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles//cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。所选 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 -检入的极简 overlay 会保留共享 SDK profile,并为 server 配置根 agent 工具 allow 列表。基础 bundle 后续新增的工具只有在 overlay 指名时才可用。其部署 persona 是完整系统提示词,因此无关引导段不会描述已隐藏的工具;动态运行时上下文、workspace 指令、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。 +[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只列出一个仓库自有组合包,该组合包会插入不含 `dsh-base` 的完整显式配置树。持久 Bash 与字符串替换 editor 通过组合存在,而不是通过 server 筛选;动态运行时上下文、workspace 指令、settings、托管凭据、遥测、compaction 与其他所有 base 配置项均不存在。同一运行时仍会把完整 `sdk` 与 `web` profile 作为独立选择打包。 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 @@ -38,7 +38,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ## 既有决策与取代关系 -本决策实现并取代[单一 dsh 应用启动器](2026-08-22-single-dsh-application-launcher.zh.md)中的 Python 例外与延后迁移章节。它取代[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的私有应用、外部完整配置、产物名称与自定义事实;后者继续负责 pkg/SEA、wheel 构建、原生目标验证与发布。没有任何 active note 被完全取代,因此无需归档。 +本决策实现并取代[单一 dsh 应用启动器](2026-08-22-single-dsh-application-launcher.zh.md)中的 Python 例外与延后迁移章节。它取代[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的私有应用、外部完整配置、产物名称与自定义事实;后者继续负责 pkg/SEA、wheel 构建、原生目标验证与发布。[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只取代本 Note 中的极简 overlay 实现。没有任何 active note 被完全取代,因此无需归档。 ## 考虑过的替代方案 @@ -52,4 +52,4 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ## 结果 -Python 调用方使用与 TypeScript 和直接 CLI 用户相同的 profile 词汇,任意外部 bundle 可以扩展 SDK profile,而无需替换应用树。调用方现在必须显式选择 home,完整配置与 `session_root` 参数不可用,可执行程序则包含共享库资源和 profile 模块代理。更强的 installed-wheel CI 将包、profile、原生与提供方路径变成发布要求,而不是仅在源码中成立的假设。 +Python 调用方使用与 TypeScript 和直接 CLI 用户相同的 profile 词汇,任意外部 bundle 可以扩展 SDK profile,而无需引入另一个 launcher。调用方必须显式选择 home,完整配置与 `session_root` 参数不可用,可执行程序则包含共享库资源和 profile 模块代理。完整 `sdk`、独立 `sdk-minimal` 与 `web` 应用作为同一打包 CLI 内的不同 profile 保持分离。Installed-wheel CI 将包、profile、原生与提供方路径变成发布要求,而不是仅在源码中成立的假设。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml new file mode 100644 index 0000000000..6fb8fc0362 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.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-24-standalone-sdk-minimal-profile.md +2026-08-24-standalone-sdk-minimal-profile.md: aa822d40a3b15bb55c36e8bcc585c4aaeb03c13d +2026-08-24-standalone-sdk-minimal-profile.zh.md: cea6ad6f575882b07d9494532e94cfe95bd61b43 diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md new file mode 100644 index 0000000000..aa822d40a3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -0,0 +1,57 @@ +# Agent Note: Standalone sdk-minimal profile under dsh + +Status: implemented + +English | [中文](2026-08-24-standalone-sdk-minimal-profile.zh.md) + +## Problem + +A minimal SDK agent needs an explicit plugin roster. Expressing it as an overlay on the full `sdk` profile leaves every `dsh-base` service mounted and makes exclusion depend on filters and disable entries spread across unrelated plugins. A later base row can change runtime behavior even when the model-facing tools remain filtered. + +A complete caller-supplied Cordis tree gives an exact roster but bypasses profile initialization, bundle resolution, persistent plugin management, home and invocation patch layers, and the `dsh`-owned process lifecycle. The minimal mode needs composition-level exclusion without creating another launcher or Python-owned application. + +## Decision + +### Launch and ownership + +`dsh --profile sdk-minimal` is a shipped startup-only profile. Its manifest lists only `@deepseek-ai/dsh-sdk-minimal`; it does not list `@deepseek-ai/dsh-base`. The bundle inserts the complete Cordis tree over the launcher's empty profile root, while the profile patch, home patch, and ordered invocation patches retain their ordinary precedence above it. + +The `dsh` CLI remains the only application launcher. The Python example selects `sdk-minimal` through the public `profile` field and an explicit Harness home. Python exposes no complete-config or arbitrary-argv path. The full Python and TypeScript SDK defaults remain `sdk`. + +The bundle reuses `@deepseek-ai/dsh-sdk-app` for command help, stdin EOF, and bounded shutdown. The startup provider accepts a profile-name config so both SDK profiles render their actual command without duplicating process lifecycle code. + +### Explicit composition + +The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, persistent Bash, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. The persona comes from `DSH_SYSTEM_PROMPT`, the adapter route from `DSH_MODEL` and `DSH_CONTEXT_WINDOW`, and the credential from `DEEPSEEK_API_KEY`. + +Harness identity, runtime context, workspace instructions, skills, model-facing job controls, compaction, settings, managed credentials, telemetry, Web tools, subagents, and every other base row are absent rather than hidden. The profile pins `danger-full-access`, `maxTokensAsSuccess: false`, and startup-only patch loading. This layer is POSIX-only because its persistent terminal uses Bash. + +### Customization and Web + +`dsh plugin --profile sdk-minimal add ` installs persistent dependencies and bundle layers. The profile's `cordis.patch.yml`, the home patch, and Python `patches` provide persistent, machine-local, and invocation-specific row changes. Customization can expand or replace the explicit tree, but it still passes through the same launcher and profile resolution. + +The Python runtime continues to package `dsh-web-app` and the frontend assets. `dsh web` starts that separate browser application from the installed wheel; a Python SDK client cannot select `web` because it contains no JSON-RPC server row. + +## Existing decisions and supersession + +This decision partially supersedes the base-first and standalone-tree rejection in [one dsh launcher for application profiles](2026-08-22-single-dsh-application-launcher.md). Repository-owned, versioned standalone profile bundles are allowed when an explicit roster is the product behavior; caller-supplied complete trees and alternate executables remain rejected. + +It also supersedes the minimal-overlay realization in [Python SDK runtime through the dsh profile launcher](2026-08-23-python-sdk-dsh-profile-runtime.md) and the base-first default-profile statement in [profile plugin bundles](2026-08-05-profile-plugin-bundles.md). Those notes retain independent authority for launcher ownership, Python packaging and home requirements, general profile layering, and plugin management. No active note is fully superseded or eligible for archival. + +## Verification + +The bundle test pins the exact row and dependency roster. Profile-template and config-dump tests pin the one-bundle manifest, startup-only lifecycle, absence of `dsh-base`, and absence of module HMR. The keyless Python example test boots the real `dsh --profile sdk-minimal` process and asserts the generated manifest, complete system prompt, and two advertised tools. The installed-wheel minimal scenario exercises persistent shell state, editor effects, JSONL persistence, and the committed model-visible snapshot through the packaged executable. + +## Alternatives considered + +**Keep the minimal mode as an overlay on `sdk`.** Rejected because filtering model-visible tools does not remove base services, prompt contributors, persistence choices, or later runtime behavior. It also makes the minimal application depend on controls in shared SDK server and system-prompt interfaces. + +**Restore a Python `cordis` argument or environment-selected complete config.** Rejected because it recreates a Python-owned application composition and bypasses profile plugin management and launcher lifecycle. + +**Create a second minimal SDK startup plugin.** Rejected because profile-aware help is the only variation; the SDK startup provider can own that config while keeping EOF and shutdown behavior local. + +**Remove Web packages from the Python runtime closure.** Rejected because the wheel distributes the ordinary `dsh` application and Python deployments may also need `dsh web`; profile selection, not packaging divergence, separates those applications. + +## Consequences + +The minimal model and runtime roster changes only when its owning bundle changes or a trusted higher patch expands it. The price is deliberate duplication of a small complete application tree and omission of shared settings, credentials, policy controls, telemetry, and Web capabilities from that profile. Users choose the full `sdk` profile when they need those services, while both choices keep one launcher, one profile vocabulary, and one packaged runtime. diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md new file mode 100644 index 0000000000..cea6ad6f57 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -0,0 +1,57 @@ +# Agent Note: dsh 下的独立 sdk-minimal profile + +Status: implemented + +[English](2026-08-24-standalone-sdk-minimal-profile.md) | 中文 + +## 问题 + +极简 SDK agent 需要显式插件清单。若把它表达为完整 `sdk` profile 上的 overlay,所有 `dsh-base` 服务仍保持挂载,排除逻辑则依赖分散在无关插件中的筛选器与 disable 配置项。以后新增的 base 配置项即使没有进入面向模型的工具清单,也可能改变运行时行为。 + +由调用方提供完整 Cordis 配置树可以得到确切清单,但会绕过 profile 初始化、组合包解析、持久插件管理、home 与调用 patch 层,以及由 `dsh` 拥有的进程生命周期。极简模式需要在组合层排除功能,同时不能创建另一个 launcher 或 Python 自有应用。 + +## 决策 + +### 启动与所有权 + +`dsh --profile sdk-minimal` 是随附的仅启动时 profile。其 manifest 只列出 `@deepseek-ai/dsh-sdk-minimal`,不列出 `@deepseek-ai/dsh-base`。该组合包在 launcher 的空 profile 根之上插入完整 Cordis 配置树,而 profile patch、home patch 与有序调用 patch 仍在其上保持普通优先级。 + +`dsh` CLI 仍是唯一应用 launcher。Python 示例通过公开 `profile` 字段与显式 Harness home 选择 `sdk-minimal`。Python 不暴露完整配置或任意 argv 路径。完整 Python 与 TypeScript SDK 的默认值仍是 `sdk`。 + +该组合包复用 `@deepseek-ai/dsh-sdk-app` 提供命令 help、stdin EOF 与有界关闭。启动提供方接受 profile 名称配置,因此两个 SDK profile 都能呈现自己的实际命令,且无需复制进程生命周期代码。 + +### 显式组合 + +该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。Persona 来自 `DSH_SYSTEM_PROMPT`,适配器路由来自 `DSH_MODEL` 与 `DSH_CONTEXT_WINDOW`,凭据来自 `DEEPSEEK_API_KEY`。 + +Harness 身份、运行时上下文、workspace 指令、skills、面向模型的 job 控制、compaction、settings、托管凭据、遥测、Web 工具、subagent 与其他所有 base 配置项均不存在,而不是被隐藏。该 profile 固定使用 `danger-full-access`、`maxTokensAsSuccess: false` 与仅启动时 patch 加载。由于持久终端使用 Bash,此层只支持 POSIX。 + +### 自定义与 Web + +`dsh plugin --profile sdk-minimal add ` 安装持久依赖与组合包层。Profile 自身的 `cordis.patch.yml`、home patch 与 Python `patches` 分别提供持久、机器本地和逐次调用的配置项变更。自定义可以扩展或替换显式配置树,但仍经过同一个 launcher 与 profile 解析。 + +Python 运行时继续打包 `dsh-web-app` 与前端产物。`dsh web` 会从已安装 wheel 启动这个独立浏览器应用;Python SDK client 不能选择 `web`,因为其中没有 JSON-RPC server 配置项。 + +## 既有决策与取代关系 + +本决策部分取代[应用 profile 使用同一个 dsh launcher](2026-08-22-single-dsh-application-launcher.zh.md)中的 base 优先规则与独立配置树否决。显式清单属于产品行为时,可以使用仓库自有且有版本的独立 profile 组合包;由调用方提供的完整配置树与替代可执行程序仍被否决。 + +本决策也取代 [Python SDK 运行时通过 dsh profile launcher 启动](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)中的极简 overlay 实现,以及 [profile 插件组合包](2026-08-05-profile-plugin-bundles.zh.md)中默认 profile 均以 base 开头的表述。这些 Agent Note 对 launcher 所有权、Python 打包与 home 要求、普通 profile 分层及插件管理仍保持独立权威。没有活跃 Agent Note 被完全取代或符合归档条件。 + +## 验证 + +组合包测试固定确切配置项与依赖清单。Profile 模板与配置 dump 测试固定单组合包 manifest、仅启动时生命周期、`dsh-base` 缺席与模块 HMR 缺席。Keyless Python 示例测试启动真实 `dsh --profile sdk-minimal` 进程,并断言生成的 manifest、完整系统提示词与两个对外公布的工具。Installed-wheel 极简场景通过打包可执行程序验证持久 shell 状态、editor 文件效果、JSONL 持久化与已提交的模型可见快照。 + +## 考虑过的替代方案 + +**继续把极简模式作为 `sdk` 上的 overlay。** 否决:筛选面向模型的工具不会移除 base 服务、提示词贡献方、持久化选择或后续运行时行为,还会让极简应用依赖共享 SDK server 与系统提示词接口中的控制项。 + +**恢复 Python `cordis` 参数或由环境选择的完整配置。** 否决:这会重新创建 Python 自有应用组合,并绕过 profile 插件管理与 launcher 生命周期。 + +**创建第二个极简 SDK 启动插件。** 否决:唯一变化是 profile 感知的 help;SDK 启动提供方可以拥有该配置,同时把 EOF 与关闭行为保持在一处。 + +**从 Python 运行时闭包移除 Web 包。** 否决:wheel 分发普通 `dsh` 应用,而且 Python 部署也可能需要 `dsh web`;这些应用由 profile 选择隔离,而不是由打包差异隔离。 + +## 后果 + +极简模型与运行时清单只有在所属组合包变化,或受信任的上层 patch 扩展它时才会变化。代价是刻意重复一棵较小的完整应用树,并在该 profile 中省略共享 settings、凭据、策略控制、遥测与 Web 功能。需要这些服务的用户选择完整 `sdk` profile;两种选择仍共用一个 launcher、一套 profile 词汇与一个打包运行时。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml index bad485523f..49ebbb6567 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.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-minimal-preset-owns-rl-composition.md -2026-08-10-minimal-preset-owns-rl-composition.md: 47a1bbe9f8f4875e2437b3ff955663c4b3de7dce -2026-08-10-minimal-preset-owns-rl-composition.zh.md: 0fab1e1f23a314bec80b5fd355abcb2881c1b1a5 +2026-08-10-minimal-preset-owns-rl-composition.md: 2e9a3e56252f8e91008a5559ad738a7ca678446b +2026-08-10-minimal-preset-owns-rl-composition.zh.md: 31df6ebfbc15f35208bc73b391725b2039fe7819 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md index 47a1bbe9f8..2e9a3e5625 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -22,7 +22,7 @@ The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace at System-prompt and persona package tests prove final complete-section and runtime-context suppression, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web-orientation text, dynamic policy contexts, and a test section are registered, asserts that no runtime-context snapshot exists, the entry-local filesystem is bare, and compaction is absent, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. -The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) is the complete two-tool composition for the bundled JSON-RPC runtime. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point. +The standalone [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) is the complete two-tool composition for `dsh --profile sdk-minimal`. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction; the [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns its launcher and bundle placement. Its keyless SDK process test asserts the assembled system prompt and two-tool catalog, and the installed-wheel scenario executes persistent Bash across calls and exercises the editor; the Python SDK tutorial provides the runnable entry point. ## Alternatives considered @@ -36,4 +36,4 @@ The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/mini ## Consequences -The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and standalone JSON-RPC example state the same two-tool contract for their respective launch paths. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset does not support Windows agents. +The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and `sdk-minimal` profile state the same two-tool behavior for their respective launch paths. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset does not support Windows agents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md index 0fab1e1f23..31df6ebfbc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -16,13 +16,13 @@ Status: implemented preset persona 恰好是 `You are a helpful software engineer assistant.`,它设置 `complete: true`,并为其 agent 作用域抑制 runtime context。complete `PromptSection` 参与常规组装,因此工具、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落,并丢弃每个动态上下文贡献。存在多个有效 complete 段时,组装会被拒绝。这些最终注册表约束可防止 harness 身份、Web 定位、工具引导、组装监听器、沙箱策略、批准策略、委派或其他动态上下文提供方添加模型输入。 -进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 会改变一个 agent 面向模型的组合,并且仅为该 agent 遮蔽宿主文件系统提供方,不会改变 Web 进程中的其他会话。 +进程级 `core-web.cordis.yml` patch 缺席。浏览器 UI、workspace 附加、持久化、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 会改变一个 agent 面向模型的组合,并且仅为该 agent 遮蔽宿主文件系统提供方,不会改变 Web 进程中的其他会话。 ## 验证 系统提示词与 persona 包测试证明了 complete 段最终约束与 runtime-context 抑制,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web 定位文本、动态策略上下文和一个测试段落;它断言不存在 runtime-context 快照、entry 本地文件系统是裸后端且压缩不存在,随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 -独立的 [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) 是内置 JSON-RPC 运行时的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)说明其启动方式专属的环境配置、裸文件系统和无压缩选择。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。 +独立的 [`sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)是 `dsh --profile sdk-minimal` 的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)说明其启动方式专属的环境配置、裸文件系统和无 compaction 选择;[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责其 launcher 与组合包位置。其无密钥 SDK 进程测试会断言组装后的系统提示词与双工具目录,installed-wheel 场景会跨调用执行持久 Bash 并使用编辑器;Python SDK 教程提供可运行入口。 ## 考虑过的替代方案 @@ -36,4 +36,4 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,它 ## 后果 -Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与独立 JSON-RPC 示例分别在各自的启动路径声明相同的双工具约定。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。 +Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与 `sdk-minimal` profile 分别为各自启动路径声明相同的双工具行为。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index 5d04c6b846..2d37aaba9f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md -2026-07-31-web-telemetry-default-mount.md: 9399cbced62e326a46058a5f0c21c949e8737eff -2026-07-31-web-telemetry-default-mount.zh.md: 133c5293fce676567a9ef8d2a47196678f4dc0e4 +2026-07-31-web-telemetry-default-mount.md: a492356eccba9f272ee216777eb518750c7b6b62 +2026-07-31-web-telemetry-default-mount.zh.md: 3d852229c069ae32f328c8dae38cfdf1744c7293 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md index 9399cbced6..a492356ecc 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -10,7 +10,7 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry ## Decision -The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every profile has one consistent telemetry capability. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps that row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. +The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps the mounted row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. | Ruling | Value | Rationale | |---|---|---| @@ -27,7 +27,7 @@ The base bundle test pins the shipped `DISABLED` mode expression, the backend su ## Alternatives considered -**No default mount; deployments add the row themselves.** Rejected because the mounted `DISABLED` mode retains a local feedback warning and gives all profiles one patch target without authorizing any upload. +**No default mount; deployments add the row themselves.** Rejected because the mounted `DISABLED` mode retains a local feedback warning and gives every base-backed profile one patch target without authorizing any upload. **A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index 133c5293fc..3d852229c0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个 profile 都具有一致的遥测能力。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)让该配置行保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 +共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)让已挂载配置项保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 | 决策项 | 取值 | 理由 | |---|---|---| @@ -28,7 +28,7 @@ Status: implemented ## 考虑过的替代方案 -**默认不挂载,部署方自行添加配置行。** 不采用:挂载的 `DISABLED` 模式会保留本地反馈警告,并为所有 profile 提供同一个 patch 目标,同时不授权任何上传。 +**默认不挂载,部署方自行添加配置行。** 不采用:挂载的 `DISABLED` 模式会保留本地反馈警告,并为每个基于 base 的 profile 提供同一个 patch 目标,同时不授权任何上传。 **开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index c548bbbcad..58474befe4 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 7d068aebb6642602aac0a039c8635acf555ccfe8 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: c32a9a2e09ff0acda592062f780427c636fd65dd +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: dbbe0e253d23a7663bdd40ac302e779d3090e8e0 +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 831b475f277beeef7e7542fb23c410311b45151b diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index 7d068aebb6..dbbe0e253d 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -12,17 +12,17 @@ The two launch paths also have different configuration owners. Web mounts a per- ## Decision -Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. Sandbox and approval services remain mounted and enforce their policies; only their model-facing dynamic context is absent. +Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. -The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) remains a complete JSON-RPC process composition. It mounts `dsh-sdk-jsonrpc-server`, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the deployment's danger-full-access sandbox policy; the editor is not confined by that policy. +The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. The [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns this bundle placement and its separation from `dsh-base`. -`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) also uses `DSH_MODEL` as its default `model` argument; an explicit `--model` remains authoritative. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. +`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) uses `DSH_MODEL` as its default `model` argument and passes an explicit `--model` back to the child environment so the catalog and request remain aligned. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. ## Verification The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services. -The SDK replay boots the real JSON-RPC agent process through the SDK client, injects an environment-selected prompt, asserts the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message, and executes both tools. Python SDK bundled-runtime coverage initializes the standalone configuration through each available packaged carrier with environment-selected model, model capacity, and prompt values. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. +The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes both tools. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. ## Alternatives considered @@ -36,4 +36,4 @@ The SDK replay boots the real JSON-RPC agent process through the SDK client, inj ## Consequences -Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path continues to communicate only through the bundled stdio JSON-RPC runtime. +Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile. diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index c32a9a2e09..831b475f27 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -12,17 +12,17 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` ## 决策 -两种随附 minimal profile 都只对外提供持久 `bash` 与 `str_replace_editor`,不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。沙箱与批准服务仍保持挂载并强制其策略;只有它们面向模型的动态上下文缺席。 +两种随附 minimal profile 都只对外提供持久 `bash` 与 `str_replace_editor`,不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 -独立的 [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) 仍是完整的 JSON-RPC 进程组合。它挂载 `dsh-sdk-jsonrpc-server`、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及未压缩的 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费部署的 danger-full-access 沙箱策略;编辑器不受该策略限制。 +独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责该组合包的位置及其与 `dsh-base` 的分离。 -`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)也使用 `DSH_MODEL` 作为 `model` 参数的默认值;显式 `--model` 仍具有最高优先级。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 +`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)会使用 `DSH_MODEL` 作为 `model` 参数的默认值,并把显式 `--model` 传回子进程环境,使目录与请求保持一致。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 ## 验证 Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。 -SDK 回放通过 SDK 客户端启动真实 JSON-RPC agent 进程,注入由环境选择的提示词,断言组装提示词与精确双工具目录,另外断言不存在任何 system-prompt 拥有的 runtime-context 消息,并执行两个工具。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立配置。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 +SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行两个工具。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 ## 考虑过的替代方案 @@ -36,4 +36,4 @@ SDK 回放通过 SDK 客户端启动真实 JSON-RPC agent 进程,注入由环 ## 后果 -Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径继续仅通过内置 stdio JSON-RPC 运行时通信。 +Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。 diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml index d2eb7495d9..534ca917cc 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md -2026-08-13-python-minimal-model-visible-snapshot.md: 37c76be93fb4f18fa99ceec7c15d20e572f2dfb3 -2026-08-13-python-minimal-model-visible-snapshot.zh.md: 5c2b0dd5fcd6ec5ea3a68eb884e19b0917bbe0be +2026-08-13-python-minimal-model-visible-snapshot.md: 1cef57c440ddd2209628016ab550174b448587bc +2026-08-13-python-minimal-model-visible-snapshot.zh.md: fbbb3bd6f4c3edadb61a02eebbae3dfaa97cb8e7 diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md index 37c76be93f..1cef57c440 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md @@ -6,15 +6,13 @@ English | [中文](2026-08-13-python-minimal-model-visible-snapshot.zh.md) ## Problem -The Python lane never compared what the minimal composition actually shows the model. Dynamic runtime context reaches history as a user message, so the mock model's assertion that system-role messages equal the deployment persona could not see it, and the advanced executable snapshot replaces each request header's assembled system prompt with a token and each tool schema with its name. The sandbox-policy runtime-context message therefore rode along in the checked-in [minimal composition](../../../../examples/python-sdk-agent/minimal.cordis.yml) while `python-runtime` stayed green, and any plugin that adds a system section, a tool, or another context message could do the same. +The Python lane needs an exact record of what the standalone minimal profile shows the model. Functional tool assertions prove execution but do not reveal an added system section, tool description, or user-role context message, while the advanced executable snapshot replaces each request header's assembled system prompt with a token and each tool schema with its name. ## Decision -The `sdk-minimal` scenario in [the packaged-runtime smoke](../../../../scripts/smoke-python-runtime.py) records `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`: for every model request of the turn, the advertised tool schemas verbatim and the message list. System and user messages keep their full text with the scenario's temporary directory tokenized; assistant and tool messages keep only call identity, because their PTY and filesystem text differs across the platforms the expected output replays on. +The `sdk-minimal` scenario in [the packaged-runtime smoke](../../../../scripts/smoke-python-runtime.py) boots the shipped profile and records `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`: for every model request of the turn, the advertised tool schemas verbatim and the message list. System and user messages keep their full text with the scenario's temporary directory tokenized; assistant and tool messages keep only call identity, because their PTY and filesystem text differs across replay platforms. The profile omits dynamic runtime context, so every message it emits is compared. -One model-visible message is excluded: the agent loop's dynamic runtime-context snapshot. The same composition emits it on macOS and not on Linux, which the required lane runs, so no single expected output can carry it. That difference is a defect in its own right ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)) — this expected output covers every other model-visible message rather than waiting for it. - -The mock model no longer asserts the minimal scenario's tools and system prompts — the snapshot owns that surface and reports a complete diff instead of the first mismatch. Snapshot comparison takes its directory and file set as arguments, so the `minimal` and `advanced` expected outputs use one implementation, and `--update-snapshots` accepts `sdk-minimal`. +The snapshot, rather than inline mock-model assertions, owns the minimal scenario's tools and system prompts and reports their complete diff. Snapshot comparison takes its directory and file set as arguments, so the `minimal` and `advanced` expected outputs use one implementation, and `--update-snapshots` accepts `sdk-minimal`. ## Alternatives considered @@ -22,12 +20,12 @@ The mock model no longer asserts the minimal scenario's tools and system prompts **Extend the mock model's inline assertions.** Every new model-visible contribution would need another hand-written expectation, and a failure names one mismatch rather than the whole surface. Tool descriptions would also be duplicated from the composition into the script. -**Rely on the TypeScript SDK snapshot.** Its `persistent-tools` scenario pins the same composition's system prompt, tool schemas, and runtime context, but through replayed model responses and a source or `lib` runtime, in a different required job. It cannot show what the deployed executable's closure assembles for a Python caller. +**Rely on the TypeScript SDK snapshot.** Its `persistent-tools` scenario pins a similar two-tool composition through replayed model responses and a source or `lib` runtime, in a different required job. It cannot show what the deployed executable's shipped profile assembles for a Python caller. ## Consequences A change to the minimal composition's model-visible surface — a system section, a tool, a tool description, or an added user message — now fails `python-runtime` with the exact diff, and landing it means rerunning `--scenario sdk-minimal --update-snapshots` and reviewing that diff. The minimal composition's tool descriptions become reviewed expected output. -Assistant and tool message text is no longer compared, and the runtime-context snapshot is not compared at all. The scenario's own assertions continue to own persistent-shell state, editor output, and the final response; [#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488) owns the excluded message until its platform difference is resolved. +Assistant and tool message text is not compared. The scenario's own assertions continue to own persistent-shell state, editor output, and the final response; the snapshot owns every model-visible message the profile emits. [AGENTS.md](../../../../AGENTS.md) and [the testing policy](../../../../docs/testing.md) now name both SDKs as independent projections of the agent loop, session lifecycle, and `SessionEventMap`, so a change to any of those carries updating both expected outputs rather than only the one a contributor happens to run. diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md index 5c2b0dd5fc..fbbb3bd6f4 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md @@ -6,15 +6,13 @@ Status: implemented ## 问题 -Python 通道从未比对极简组合实际展示给模型的内容。动态运行时上下文以 user 消息进入历史,因此 mock 模型"system 角色消息等于部署 persona"的断言看不见它;而进阶可执行文件快照会把每个请求头中已组装的系统提示词换成占位符、把每个工具 schema 换成其名称。于是 sandbox-policy 的运行时上下文消息一直搭车留在签入的[极简组合](../../../../examples/python-sdk-agent/minimal.cordis.yml)里,而 `python-runtime` 始终是绿的;任何新增系统分段、工具或其他上下文消息的插件都能照此蒙混过关。 +Python 通道需要精确记录独立极简 profile 实际展示给模型的内容。功能性工具断言可以证明执行,但无法发现新增系统分段、工具描述或 user 角色上下文消息;而进阶可执行文件快照会把每个请求头中已组装的系统提示词换成占位符,并把每个工具 schema 换成其名称。 ## 决策 -[打包运行时冒烟测试](../../../../scripts/smoke-python-runtime.py)的 `sdk-minimal` 场景会录制 `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`:对该回合的每个模型请求,逐字记录对外公布的工具 schema 与消息列表。system 与 user 消息保留全文,仅将场景的临时目录替换为占位符;assistant 与 tool 消息只保留调用标识,因为它们的 PTY 与文件系统文本在期望输出需要重放的各平台上并不相同。 +[打包运行时冒烟测试](../../../../scripts/smoke-python-runtime.py)的 `sdk-minimal` 场景会启动随附 profile,并录制 `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`:对该回合的每个模型请求,逐字记录对外公布的工具 schema 与消息列表。system 与 user 消息保留全文,仅将场景的临时目录替换为占位符;assistant 与 tool 消息只保留调用标识,因为它们的 PTY 与文件系统文本在各回放平台上并不相同。该 profile 省略动态运行时上下文,因此它发出的每条消息都会参与比对。 -有一条模型可见消息被排除在外:agent loop 的动态运行时上下文快照。同一组合在 macOS 上会发出它,在必需车道所用的 Linux 上不会,因此任何单一期望输出都无法承载它。该差异本身就是缺陷([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))——这份期望输出覆盖其余全部模型可见消息,而不是等它先被修复。 - -mock 模型不再断言极简场景的工具与系统提示词——该面由快照拥有,并给出完整差异而非首个不匹配项。快照比对以目录与文件集合为参数,因此 `minimal` 与 `advanced` 两份期望输出共用一套实现,且 `--update-snapshots` 接受 `sdk-minimal`。 +极简场景的工具与系统提示词由快照拥有,而不是由 mock 模型内联断言;快照会给出其完整差异。快照比对以目录与文件集合为参数,因此 `minimal` 与 `advanced` 两份期望输出共用一套实现,且 `--update-snapshots` 接受 `sdk-minimal`。 ## 曾考虑的替代方案 @@ -22,12 +20,12 @@ mock 模型不再断言极简场景的工具与系统提示词——该面由快 **扩展 mock 模型中的内联断言。** 每新增一项模型可见贡献都要再手写一条期望,且失败只会指出一处不匹配而非整个面。工具描述还会从组合复制进脚本,形成重复。 -**依赖 TypeScript SDK 快照。** 其 `persistent-tools` 场景固定了同一组合的系统提示词、工具 schema 与运行时上下文,但走的是重放的模型响应与 source 或 `lib` 运行时,且位于另一个必需任务中。它无法体现已部署可执行文件的闭包为 Python 调用方组装出什么。 +**依赖 TypeScript SDK 快照。** 其 `persistent-tools` 场景通过重放模型响应与 source 或 `lib` 运行时固定一套相似的双工具组合,且位于另一个必需任务中。它无法体现已部署可执行文件的随附 profile 为 Python 调用方组装出什么。 ## 后果 极简组合模型可见面的改动——系统分段、工具、工具描述或新增的 user 消息——现在会让 `python-runtime` 带着精确差异失败;要让它落地,就必须重新运行 `--scenario sdk-minimal --update-snapshots` 并审阅该差异。极简组合的工具描述由此成为经过审阅的期望输出。 -assistant 与 tool 消息文本不再参与比对,运行时上下文快照则完全不参与比对。持久 shell 状态、编辑器输出与最终响应仍由该场景自身的断言拥有;被排除的那条消息由 [#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488) 负责,直到其平台差异得到解决。 +assistant 与 tool 消息文本不参与比对。持久 shell 状态、编辑器输出与最终响应仍由该场景自身的断言拥有;快照负责该 profile 发出的每条模型可见消息。 [AGENTS.md](../../../../AGENTS.md) 与[测试政策](../../../../docs/testing.zh.md)现已点明两个 SDK 都是 agent loop、会话生命周期与 `SessionEventMap` 的独立投影,因此改动其中任何一项都要连带更新两侧的期望输出,而不只是贡献者恰好会运行的那一侧。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index c23418172b..f73476f371 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: b64861ce68b4c6de8edc3142fb95d83f1d395d15 -README.zh.md: a42a1b0539703dc03adf0ac53e8b5819940429a2 +README.md: ff0efb03d8a5d6da747f1b5bc0d05361a6c8a567 +README.zh.md: 8e9c122afa87525c99e8045f220b96b4511c04fe diff --git a/apps/cli/README.md b/apps/cli/README.md index b64861ce68..ff0efb03d8 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command and launches its `sdk` profile. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. +The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. ## Entry modes @@ -12,10 +12,11 @@ The `dsh` command is the sole supported Node application launcher: profiles are | `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. | | `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. | | `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. | +| `dsh --profile sdk-minimal` | Serve SDK clients with the standalone minimal agent tree. | | `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. ## App arguments @@ -38,7 +39,7 @@ The tree composes over an empty root: - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml` - then `--patch` overlays -Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. +Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index a42a1b0539..8e9c122afa 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。Python 运行时 wheel 会打包同一个命令,并启动其 `sdk` profile。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 +`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。Python 运行时 wheel 会打包同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 ## 入口模式 @@ -12,10 +12,11 @@ | `dsh --profile acp` | 通过 ACP stdio 为自动化 client 提供服务,直至断开连接。 | | `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 | | `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK client 提供服务,直至关闭或断开连接。 | +| `dsh --profile sdk-minimal` | 以独立极简 agent 配置树为 SDK client 提供服务。 | | `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 ## 应用参数 @@ -40,7 +41,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml` - `--patch` 指定的覆盖层 -`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 +`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9e119a6100..9ec9a9a22d 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -3,7 +3,7 @@ # DSH Base Composition -The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user's profile layer patch over it. +The dsh-base bundle patch shared by the web, headless, sdk, and acp profiles; their mode bundles and user layers patch over it, while sdk-minimal owns a separate standalone tree. ```mermaid flowchart LR diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index f432cc953b..96e7b4c7b0 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 2692731ecdfb4a3f798ca0506954289f2c9bdca8 -README.zh.md: 0a2c0a14151f7b774ed00aab808c7313716c131a +README.md: 33de399dc4b2b8e67ed24ed046fcc0da2ff0a7ac +README.zh.md: 0f20245f318e31159780d29cca955fc85a5b5481 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 2692731ecd..33de399dc4 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -8,9 +8,9 @@ This reference defines the profile, web-alias, plugin-management, and config-dum `dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. -The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. +The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. ### App arguments @@ -27,6 +27,7 @@ The shipped apps own these command lines: | `web` | `--host`, `--port`, repeatable `--trusted-host`, `--no-open` | | `headless` | the task text, as the positional argument | | `sdk` | no options; stdio carries the JSON-RPC protocol | +| `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol | | `acp` | no options; stdio carries Agent Client Protocol | A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. @@ -80,9 +81,9 @@ The production Web runner needs built package and frontend artifacts (`pnpm run Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. +The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its local filesystem and sandbox-policy root but intentionally omits instruction discovery and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. -New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. +New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. `DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 0a2c0a1415..0f20245f31 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -8,9 +8,9 @@ `dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 -组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。 +组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。 -`web`、`headless`、`sdk` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 +`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 ### 应用参数 @@ -27,6 +27,7 @@ | `web` | `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` | | `headless` | 任务文本,作为位置参数 | | `sdk` | 无选项;stdio 携带 JSON-RPC 协议 | +| `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 | | `acp` | 无选项;stdio 携带 Agent Client Protocol | 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 @@ -80,9 +81,9 @@ dsh web --help 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 -所有模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 +基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为本地文件系统与沙箱策略根目录,但刻意省略指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 -新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 +基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 `DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 1c77b1ff8a..30058a380f 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -145,8 +145,8 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { /** * Load `name` and compose its effective patch stack: bundle layers in - * `dsh.profile.bundles` order (the base bundle gates the shell stacks by - * platform on its own rows), the profile's user layer, the home-level user + * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's + * platform-gated shell rows), the profile's user layer, the home-level user * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply * to every profile, so it outranks the per-profile layer), `--patch` overlays, * then the telemetry switch. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index fe84ec6adf..82e57fd2f7 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 362a3758fabdb67ea883bcf84ea5df4cf4e77b0f -architecture.zh.md: 421c38ad5b90db7b7ac1b5fe6bb10ccc0793feb5 +architecture.md: add615c252948adab8db7dec7059f94b8f45e52c +architecture.zh.md: 48baf14d6a29e5ef77e6e47f4d9fa9adc4e9e748 diff --git a/docs/architecture.md b/docs/architecture.md index 362a3758fa..add615c252 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,17 +16,17 @@ There is no privileged core to patch: you extend dsh by mounting a plugin beside A running `dsh` is a plugin tree composed at boot from ordered layers. -A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`. `web`, `headless`, `sdk`, and `acp` ship as templates. +A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`. `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` ship as templates. A **bundle** is a distribution format for Cordis config rows and the code they mount, so whatever it inserts stays patchable by the layers above it. Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile` lists a profile's bundles, and `dsh.bundle` points at a bundle's patch file. -[`dsh-base`](../packages/bundle/base/README.md) is the first layer of every profile: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry. [`dsh-web-app`](../packages/bundle/web-app/README.md) adds the browser application, [`dsh-headless`](../packages/bundle/headless/README.md) adds a one-shot runner with no server, [`dsh-sdk-app`](../packages/bundle/sdk-app/README.md) adds the SDK JSON-RPC server, and [`dsh-acp-app`](../packages/bundle/acp-app/README.md) adds the automation-only ACP server. +[`dsh-base`](../packages/bundle/base/README.md) is the shared first layer of the `web`, `headless`, `sdk`, and `acp` profiles: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry. [`dsh-web-app`](../packages/bundle/web-app/README.md) adds the browser application, [`dsh-headless`](../packages/bundle/headless/README.md) adds a one-shot runner with no server, [`dsh-sdk-app`](../packages/bundle/sdk-app/README.md) adds the SDK JSON-RPC server, and [`dsh-acp-app`](../packages/bundle/acp-app/README.md) adds the automation-only ACP server. [`dsh-sdk-minimal`](../packages/bundle/sdk-minimal/README.md) is the deliberate exception: one bundle owns its complete explicit SDK tree and does not apply `dsh-base`. Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows. -Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle. +Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, `sdk-minimal`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle. To see the tree your machine actually boots: @@ -40,11 +40,11 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof ## Application launch -Every supported Node application starts at the `dsh` CLI with a named profile. The shipped applications are `dsh web` (the deliberate alias for `--profile web`), `dsh --profile headless`, `dsh --profile sdk`, and `dsh --profile acp`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. +Every supported Node application starts at the `dsh` CLI with a named profile. The shipped applications are `dsh web` (the deliberate alias for `--profile web`), `dsh --profile headless`, `dsh --profile sdk`, `dsh --profile sdk-minimal`, and `dsh --profile acp`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree. Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. -The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. +The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. ## Core packages diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 421c38ad5b..48baf14d6a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -16,17 +16,17 @@ 运行中的 `dsh` 是一棵插件树,由启动时按序叠加的各层组合而成。 -**profile** 是存放在 Harness home 中的具名组装。它列出自己叠放的组合包,存放自己安装的树外插件,并保存用户自己的 `cordis.patch.yml`。`web`、`headless`、`sdk` 和 `acp` 作为模板随发行版交付。 +**profile** 是存放在 Harness home 中的具名组装。它列出自己叠放的组合包,存放自己安装的树外插件,并保存用户自己的 `cordis.patch.yml`。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` 作为模板随发行版交付。 **组合包**是 Cordis 配置项及其挂载代码的分发格式,因此它插入的内容始终可被其上各层 patch。 两者都在各自的 `package.json` 中通过 `dsh` 字段声明自己:`dsh.profile` 列出一个 profile 的组合包,`dsh.bundle` 指向一个组合包的 patch 文件。 -[`dsh-base`](../packages/bundle/base/README.zh.md) 是每个 profile 的第一层:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。[`dsh-web-app`](../packages/bundle/web-app/README.zh.md) 增加浏览器应用,[`dsh-headless`](../packages/bundle/headless/README.zh.md) 增加不带服务器的一次性运行器,[`dsh-sdk-app`](../packages/bundle/sdk-app/README.zh.md) 增加 SDK JSON-RPC 服务器,[`dsh-acp-app`](../packages/bundle/acp-app/README.zh.md) 增加仅用于自动化的 ACP 服务器。 +[`dsh-base`](../packages/bundle/base/README.zh.md) 是 `web`、`headless`、`sdk` 与 `acp` profile 的共享第一层:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。[`dsh-web-app`](../packages/bundle/web-app/README.zh.md) 增加浏览器应用,[`dsh-headless`](../packages/bundle/headless/README.zh.md) 增加不带服务器的一次性运行器,[`dsh-sdk-app`](../packages/bundle/sdk-app/README.zh.md) 增加 SDK JSON-RPC 服务器,[`dsh-acp-app`](../packages/bundle/acp-app/README.zh.md) 增加仅用于自动化的 ACP 服务器。[`dsh-sdk-minimal`](../packages/bundle/sdk-minimal/README.zh.md) 是刻意保留的例外:一个组合包拥有完整的显式 SDK 配置树,不应用 `dsh-base`。 各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。 -自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。 +自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk`、`sdk-minimal` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。 要查看你的机器实际启动的配置树: @@ -40,11 +40,11 @@ dsh --profile web --dump-config ## 应用启动 -所有受支持的 Node 应用都从 `dsh` CLI 与具名 profile 启动。随附应用是 `dsh web`(刻意为 `--profile web` 保留的别名)、`dsh --profile headless`、`dsh --profile sdk` 与 `dsh --profile acp`。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。 +所有受支持的 Node 应用都从 `dsh` CLI 与具名 profile 启动。随附应用是 `dsh web`(刻意为 `--profile web` 保留的别名)、`dsh --profile headless`、`dsh --profile sdk`、`dsh --profile sdk-minimal` 与 `dsh --profile acp`。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。 Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 -Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端以显式 Harness home 启动 `dsh --profile sdk`。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 +Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 ## 核心包 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4998dcd505..eabedcfe3c 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: cb156837fa0768dfa74d9b351cc3e362449ca248 -config-catalog.zh.md: 151a72190332a400b643111495d36e2d7f205d93 +config-catalog.md: 7f7e5d3c58953ea50c43eed0d903f0d5469e7849 +config-catalog.zh.md: f9ab7a8537e29cb0e74e05e74b4a7890d146c28b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cb156837fa..7f7e5d3c58 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1681,6 +1681,22 @@ Depends on: [`SandboxMode`](subsystems/sandbox.md) Source: [`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) + + +## `@deepseek-ai/dsh-sdk-app` + +Requires: `cmdlineArgs` + +```ts config-catalog +/** SDK stdio startup configuration. */ +export interface Config { + /** Profile name rendered in help and diagnostics (default `sdk`). */ + profile?: string +} +``` + +Source: [`packages/bundle/sdk-app/src/index.ts:23`](../packages/bundle/sdk-app/src/index.ts) + ## `@deepseek-ai/dsh-sdk-jsonrpc-server` @@ -3330,7 +3346,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) -- `@deepseek-ai/dsh-sdk-app` — requires `cmdlineArgs` ([`packages/bundle/sdk-app/src/index.ts`](../packages/bundle/sdk-app/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-log-export` — requires `commands` ([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) @@ -3400,6 +3415,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) +- `@deepseek-ai/dsh-sdk-minimal` ([`packages/bundle/sdk-minimal/src/index.ts`](../packages/bundle/sdk-minimal/src/index.ts)) - `@deepseek-ai/dsh-sdk-protocol` ([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 151a721903..f9ab7a8537 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1683,6 +1683,22 @@ export interface Config { 来源:[`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) + + +## `@deepseek-ai/dsh-sdk-app` + +需要:`cmdlineArgs` + +```ts config-catalog +/** SDK stdio startup configuration. */ +export interface Config { + /** Profile name rendered in help and diagnostics (default `sdk`). */ + profile?: string +} +``` + +来源:[`packages/bundle/sdk-app/src/index.ts:23`](../packages/bundle/sdk-app/src/index.ts) + ## `@deepseek-ai/dsh-sdk-jsonrpc-server` @@ -3332,7 +3348,6 @@ export interface Config { - `@deepseek-ai/dsh-llm`([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp`([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) -- `@deepseek-ai/dsh-sdk-app` — 需要 `cmdlineArgs`([`packages/bundle/sdk-app/src/index.ts`](../packages/bundle/sdk-app/src/index.ts)) - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-log-export` — 需要 `commands`([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) @@ -3401,6 +3416,7 @@ export interface Config { - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) +- `@deepseek-ai/dsh-sdk-minimal`([`packages/bundle/sdk-minimal/src/index.ts`](../packages/bundle/sdk-minimal/src/index.ts)) - `@deepseek-ai/dsh-sdk-protocol`([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index e5f37e4a8c..a7edea20b2 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: d401df81c59de27a0171750d92031c40939c6564 -module-graph.zh.md: 8186217711a2ca320017dc0473227f7a9cf59b1a +module-graph.md: a2497ed002c6eec55ec35e1d7e9953feb2ed0ee5 +module-graph.zh.md: 230d67f4c07f9715d4b5157c1539edd09f9221c3 diff --git a/docs/module-graph.md b/docs/module-graph.md index d401df81c5..a2497ed002 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -123,6 +123,7 @@ flowchart TD pkg_base["base"] pkg_headless["headless"] pkg_sdk_app["sdk-app"] + pkg_sdk_minimal["sdk-minimal"] pkg_web_app["web-app"] end subgraph group_client["packages/client"] @@ -366,6 +367,7 @@ flowchart TD pkg_acp_app --> pkg_invariants pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants + pkg_sdk_minimal --> pkg_invariants pkg_client_store --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_renderer --> pkg_invariants @@ -1656,6 +1658,7 @@ flowchart TD | [`acp-app`](../packages/bundle/acp-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-store`](../packages/client/store) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8186217711..230d67f4c0 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -125,6 +125,7 @@ flowchart TD pkg_base["base"] pkg_headless["headless"] pkg_sdk_app["sdk-app"] + pkg_sdk_minimal["sdk-minimal"] pkg_web_app["web-app"] end subgraph group_client["packages/client"] @@ -368,6 +369,7 @@ flowchart TD pkg_acp_app --> pkg_invariants pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants + pkg_sdk_minimal --> pkg_invariants pkg_client_store --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_renderer --> pkg_invariants @@ -1658,6 +1660,7 @@ flowchart TD | [`acp-app`](../packages/bundle/acp-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-store`](../packages/client/store) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 69e9720f11..88d467f519 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/python-sdk.md -python-sdk.md: 98d7f8160dee0874338c1d914796dcfb72400a48 -python-sdk.zh.md: 8a0417b24be859b8d327a8650ce1db23adb4dba2 +python-sdk.md: 2cf6acbd6718be560ec4365b356c031b17fb070e +python-sdk.zh.md: 2c53886ba3409e6fc21ec57fc79b81bf6907349c diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 98d7f8160d..2cf6acbd67 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -2,7 +2,7 @@ English | [中文](python-sdk.zh.md) -This tutorial installs the published Python SDK, runs the checked-in minimal profile overlay, and shows how to customize the same `dsh` profile from your own program. +This tutorial installs the published Python SDK, runs the shipped standalone minimal profile, and shows how to customize the same `dsh` profile from your own program. ## Prerequisites @@ -43,7 +43,7 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -The script prints the final assistant response. The selected home receives the generated `sdk` profile, settings, credentials if you add them, installed plugins, and Zstandard session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. +The script prints the final assistant response. The selected home receives the generated `sdk-minimal` profile, installed plugins, and uncompressed JSONL session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. ## Use the SDK in your program @@ -54,16 +54,14 @@ from deepseek_harness import DeepSeekHarness workspace = Path("/absolute/path/to/disposable-workspace").resolve() dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() -patch = Path("examples/python-sdk-agent/minimal.patch.yml").resolve() - with DeepSeekHarness( provider="deepseek-official", model="deepseek-v4-flash", max_tokens=49_152, cwd=str(workspace), dsh_home=str(dsh_home), - profile="sdk", - patches=(str(patch),), + profile="sdk-minimal", + env={"DSH_MODEL": "deepseek-v4-flash"}, ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", @@ -73,7 +71,7 @@ with DeepSeekHarness( print(result.final_response) ``` -The SDK starts the bundled `dsh --profile sdk` process lazily and reuses it until context-manager exit. The profile, its persistent patch, the home patch, and the ordered `patches` tuple form the application configuration. There is no separate Python runtime bin or complete-config option. +The SDK starts the bundled `dsh --profile sdk-minimal` process lazily and reuses it until context-manager exit. The profile, its persistent patch, the home patch, and any ordered `patches` tuple form the application configuration. There is no separate Python runtime bin or complete-config option. ## Install or define plugins @@ -81,15 +79,15 @@ Use `dsh plugin` for dependencies and bundle layers that should persist in this ```sh export DSH_HOME=/absolute/path/to/example-dsh-home -dsh --profile sdk --dump-default-config >/dev/null -dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +dsh --profile sdk-minimal --dump-default-config >/dev/null +dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -The first command initializes the shipped SDK profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. +The first command initializes the shipped standalone profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. -## Understand the minimal overlay +## Understand the minimal profile | Property | Value | |---|---| @@ -98,11 +96,13 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe | Model-facing tools | Persistent `bash` and `str_replace_editor` only | | Bash timeout | 300 seconds | | Editor output limit | 16,000 characters | -| Context compaction | Disabled | -| Session persistence | Zstandard JSONL under `/sessions` | +| Runtime context and compaction | Absent | +| Session persistence | Uncompressed JSONL under `/sessions` | -The overlay allowlists persistent Bash and the editor for every SDK-created root agent, so later base-profile tools cannot appear implicitly. It suppresses unrelated prompt sections and runtime-context messages, disables local instruction discovery and compaction, and retains the SDK application's protocol, persistence, policy, settings, credentials, and providers. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. +The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so persistent Bash and the editor can modify any path visible to the runtime; use a disposable checkout or container. The PTY implementation makes this example POSIX-only. + +The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. Use a fresh home when profiles, plugins, credentials, settings, and sessions must be isolated. Use a fresh session id for independent work; reuse a harness, home, and id only to continue the same durable conversation and session-owned resources. -The [example reference](../../../examples/python-sdk-agent/README.md) owns the checked-in overlay. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, and low-level behavior; the [dsh CLI reference](../../../apps/cli/reference/README.md) covers profile layering. +The [bundle reference](../../../packages/bundle/sdk-minimal/README.md) owns the exact tree, and the [example reference](../../../examples/python-sdk-agent/README.md) owns the runnable program. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, and low-level behavior; the [dsh CLI reference](../../../apps/cli/reference/README.md) covers profile layering. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 8a0417b24b..2c53886ba3 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -2,7 +2,7 @@ [English](python-sdk.md) | 中文 -本教程安装已发布的 Python SDK,运行检入的极简 profile overlay,并说明如何从自己的程序自定义同一个 `dsh` profile。 +本教程安装已发布的 Python SDK,运行随附的独立极简 profile,并说明如何从自己的程序自定义同一个 `dsh` profile。 ## 前置条件 @@ -43,7 +43,7 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk` profile、设置、调用方添加的凭据、已安装插件,以及 `sessions/` 下的 Zstandard 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 +脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk-minimal` profile、已安装插件,以及 `sessions/` 下的未压缩 JSONL 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 ## 在程序中使用 SDK @@ -54,16 +54,14 @@ from deepseek_harness import DeepSeekHarness workspace = Path("/absolute/path/to/disposable-workspace").resolve() dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() -patch = Path("examples/python-sdk-agent/minimal.patch.yml").resolve() - with DeepSeekHarness( provider="deepseek-official", model="deepseek-v4-flash", max_tokens=49_152, cwd=str(workspace), dsh_home=str(dsh_home), - profile="sdk", - patches=(str(patch),), + profile="sdk-minimal", + env={"DSH_MODEL": "deepseek-v4-flash"}, ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", @@ -73,7 +71,7 @@ with DeepSeekHarness( print(result.final_response) ``` -SDK 会延迟启动内置的 `dsh --profile sdk` 进程,并复用到上下文管理器退出。Profile、其持久 patch、home patch 与有序 `patches` tuple 共同组成应用配置。不存在独立 Python 运行时 bin 或完整配置选项。 +SDK 会延迟启动内置的 `dsh --profile sdk-minimal` 进程,并复用到上下文管理器退出。Profile、其持久 patch、home patch 与任何有序 `patches` tuple 共同组成应用配置。不存在独立 Python 运行时 bin 或完整配置选项。 ## 安装或定义插件 @@ -81,15 +79,15 @@ SDK 会延迟启动内置的 `dsh --profile sdk` 进程,并复用到上下文 ```sh export DSH_HOME=/absolute/path/to/example-dsh-home -dsh --profile sdk --dump-default-config >/dev/null -dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +dsh --profile sdk-minimal --dump-default-config >/dev/null +dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -第一个命令初始化随附的 SDK profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 +第一个命令初始化随附的独立 profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 -## 理解极简 overlay +## 理解极简 profile | 属性 | 值 | |---|---| @@ -98,11 +96,13 @@ dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle | 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | | Bash 超时 | 300 秒 | | Editor 输出上限 | 16,000 字符 | -| 上下文压缩 | 禁用 | -| 会话持久化 | `/sessions` 下的 Zstandard JSONL | +| 运行时上下文与 compaction | 不存在 | +| 会话持久化 | `/sessions` 下的未压缩 JSONL | -该 overlay 会为每个由 SDK 创建的根 agent allowlist 持久 Bash 与 editor,因此基础 profile 以后新增的工具不会隐式出现。它会抑制无关提示词段与运行时上下文消息,停用本地指令发现与 compaction,并保留 SDK 应用的协议、持久化、策略、settings、credentials 与 provider。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 +该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此持久 Bash 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 + +已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 需要隔离 profile、插件、凭据、设置与会话时,应使用新的 home。独立工作应使用新的 session id;只有继续同一段持久对话和会话资源时,才同时复用 harness、home 与 id。 -[示例参考](../../../examples/python-sdk-agent/README.zh.md)定义检入 overlay。[Python SDK 参考](../../../python/sdk/README.zh.md)介绍生命周期、结果、通知与底层行为;[dsh CLI 参考](../../../apps/cli/reference/README.zh.md)介绍 profile 分层。 +[组合包参考](../../../packages/bundle/sdk-minimal/README.zh.md)定义确切配置树,[示例参考](../../../examples/python-sdk-agent/README.zh.md)定义可运行程序。[Python SDK 参考](../../../python/sdk/README.zh.md)介绍生命周期、结果、通知与底层行为;[dsh CLI 参考](../../../apps/cli/reference/README.zh.md)介绍 profile 分层。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index f0f1ef1980..0c3a5fe203 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-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 examples/python-sdk-agent/README.md -README.md: b1718bfa3fb6ea4cb241bb4211b58a21bacc17a2 -README.zh.md: dbc4053093f66a60a18ab02bb727611a279fe03c +README.md: 5bb958c3e9202642926a0186d055615a895d9586 +README.zh.md: 97bfe9e1d19a7cd37ed41fccc64340dc054ecec7 diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index b1718bfa3f..5bb958c3e9 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Runnable Python SDK example over the sole application launcher, `dsh --profile sdk`. The Python client owns JSON-RPC stdio; the profile owns the agent composition, persistence, permissions, and plugins. +Runnable Python SDK example over the sole application launcher, `dsh --profile sdk-minimal`. The Python client owns JSON-RPC stdio; the profile owns the agent composition, persistence, execution policy, and plugins. ## Run the minimal agent @@ -17,14 +17,14 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` and `--profile` override their script defaults. The selected home stores the generated profile and Zstandard session logs under `sessions/`; the script never reads `~/.dsh` implicitly. +Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` overrides the model and passes the same value to the profile-owned adapter catalog; `--profile` can select another SDK-serving profile. The selected home stores the generated `sdk-minimal` profile and uncompressed JSONL session logs under `sessions/`; the script never reads `~/.dsh` implicitly. -[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. Its root-agent tool allowlist exposes exactly: +The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../packages/bundle/sdk-minimal/README.md) is the complete explicit Cordis tree for this mode. It exposes exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -The allowlist excludes every other current or later global tool without requiring a disable entry for each base row. A complete deployment persona suppresses unrelated tool-guidance sections; runtime-context messages, local instruction discovery, compaction, and the conflicting one-shot Bash row are disabled separately. The overlay inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. Other SDK-profile services remain mounted, including persistence, policy, settings, credentials, and providers. +The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, local instruction discovery, compaction, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. @@ -34,9 +34,11 @@ Use the runtime wheel's `dsh` command against the same explicit home for persist ```sh export DSH_HOME=/absolute/path/to/example-dsh-home -dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -The Python call can also pass additional absolute patch paths in `patches=(...)`; later files win. A selected profile must retain `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Complete standalone Cordis files in this directory remain test fixtures for lower-level composition coverage; they are not Python SDK launch interfaces. +Use `sdk-minimal` in that command to extend this example, or `sdk` to extend the full base-backed SDK profile. The Python call can also pass additional absolute patch paths in `patches=(...)`; later files win. A selected profile must retain `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. The example accepts no complete Cordis file or arbitrary process argv. + +The same runtime wheel packages the `web` profile and its frontend assets for direct CLI use: `dsh web` starts that separate application. A Python SDK client cannot select `web` because it has no JSON-RPC server row. See the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) and [SDK reference](../../python/sdk/README.md). diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index dbc4053093..97bfe9e1d1 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -基于唯一应用启动器 `dsh --profile sdk` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;profile 负责 agent 组合、持久化、权限与插件。 +基于唯一应用启动器 `dsh --profile sdk-minimal` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;profile 负责 agent 组合、持久化、执行策略与插件。 ## 运行极简 agent @@ -17,14 +17,14 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 与 `--profile` 会覆盖脚本默认值。所选 home 保存生成的 profile,并在 `sessions/` 下保存 Zstandard 会话日志;脚本绝不会隐式读取 `~/.dsh`。 +兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 会覆盖模型,并将同一值传给 profile 自有的适配器目录;`--profile` 可以选择另一个提供 SDK 服务的 profile。所选 home 保存生成的 `sdk-minimal` profile,并在 `sessions/` 下保存未压缩 JSONL 会话日志;脚本绝不会隐式读取 `~/.dsh`。 -[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。其根 agent 工具 allow 列表只暴露: +随附的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../packages/bundle/sdk-minimal/README.zh.md)是该模式完整且显式的 Cordis 配置树。它只暴露: - agent 所有的持久 `bash` - 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -Allow 列表会排除当前及以后出现的其他所有全局工具,无需为每个基础配置项添加 disable。完整部署 persona 会抑制无关工具的引导段;运行时上下文消息、本地指令发现、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。该 overlay 会插入本地 PTY 与持久 Bash provider,并将 editor 输出上限设为 16,000 字符。其他 SDK profile 服务仍保持挂载,包括持久化、策略、settings、credentials 与 provider。 +该组合包不包含 `dsh-base`,因此每一个新增配置项都是显式 profile 变更。运行时上下文、本地指令发现、compaction、settings、托管凭据、遥测、Web 工具、subagent 与完整默认工具清单均不存在。配置树保留 SDK 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、本地执行和 JSONL 持久化。 此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 @@ -34,9 +34,11 @@ Allow 列表会排除当前及以后出现的其他所有全局工具,无需 ```sh export DSH_HOME=/absolute/path/to/example-dsh-home -dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -Python 调用也可以在 `patches=(...)` 中传入更多绝对 patch 路径;后面的文件优先。所选 profile 必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项。本目录中的完整独立 Cordis 文件仍作为底层组合测试 fixture;它们不是 Python SDK 启动接口。 +在该命令中使用 `sdk-minimal` 可扩展本示例,使用 `sdk` 则扩展基于完整 base 的 SDK profile。Python 调用也可以在 `patches=(...)` 中传入更多绝对 patch 路径;后面的文件优先。所选 profile 必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项。该示例不接受完整 Cordis 文件或任意进程 argv。 + +同一个运行时 wheel 还为直接 CLI 使用打包 `web` profile 及其前端产物:`dsh web` 会启动这个独立应用。Python SDK client 不能选择 `web`,因为其中没有 JSON-RPC server 配置项。 另见 [Python SDK 教程](../../docs/user/guide/python-sdk.zh.md)与 [SDK 参考](../../python/sdk/README.zh.md)。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ae60e877c0..7f7c3badcf 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 0adcb0ac20516b1eea97792a11471a383a3ab429 -README.zh.md: 1da1725b1e0681f83f2c82b82cb9bbaac8ea90cb +README.md: a7c6272fd96fdf24b4f21bb8b60c087af4a4dc96 +README.zh.md: 09a63765414ba76d78a7c88f3777f63eecd835f7 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 0adcb0ac20..a7c6272fd9 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. A complete matching generation returns without acquiring the writer lock. A missing or stale entry acquires the cross-process lock, rechecks the full generation, and repairs it without exposing partial proxies; either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. A complete matching generation returns without acquiring the writer lock. A missing or stale entry acquires the cross-process lock, rechecks the full generation, and repairs it without exposing partial proxies; either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`sdk-minimal`/`acp` with startup-only patches; `sdk-minimal` lists only its standalone bundle, while the other templates retain their base-plus-mode stacks. Other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 1da1725b1e..09a6376541 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。完整且匹配的 generation 不会获取写入锁。缺失或过期的配置项会获取跨进程锁、重新检查完整 generation,并在不暴露半成品代理的前提下修复;两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。完整且匹配的 generation 不会获取写入锁。缺失或过期的配置项会获取跨进程锁、重新检查完整 generation,并在不暴露半成品代理的前提下修复;两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`sdk-minimal`/`acp`;`sdk-minimal` 只列出自己的独立组合包,其他模板保留 base 加模式层的组合。其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index 1ca618d528..6ad818ee64 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/README.md -README.md: 44cf46a217e078e4d71d838afb6525f2bc908bd2 -README.zh.md: f2987b72c526698e2df949e2edfe55e1375f2d8e +README.md: c0ea5be0c6f1b2457166b24a1718f6ab3aa0ffe6 +README.zh.md: 6c3520668d8f83cfbdc75652fac81f29d9bb810d diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 44cf46a217..c0ea5be0c6 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -8,7 +8,7 @@ The manifest declaration, not this directory, defines Bundle identity. Domain pa | Package | Role | ctx key | |---|---|---| -| [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) | +| [`base/`](base/README.md) | The shared dsh core applied first by base-backed profiles | — (patch only) | | [`acp-app/`](acp-app/README.md) | Automation-only ACP stdio application over base | mounts the ACP bridge | | [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows | | [`headless/`](headless/README.md) | Direct one-shot task mode over base, with no Host or Web layer | mounts `headless-runner` | diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index f2987b72c5..6c3520668d 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -8,7 +8,7 @@ Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包 | 包 | 职责 | ctx key | |---|---|---| -| [`base/`](base/README.zh.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) | +| [`base/`](base/README.zh.md) | 基于 base 的 profile 最先应用的共享 dsh 核心 | —(仅 patch) | | [`acp-app/`](acp-app/README.zh.md) | 运行在 base 之上的 automation-only ACP stdio 应用 | 挂载 ACP bridge | | [`web-app/`](web-app/README.zh.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 | | [`headless/`](headless/README.zh.md) | 直接运行在 base 之上的一次性任务模式,不含 Host 或 Web 层 | 挂载 `headless-runner` | diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index bfcd5c6a66..0ab5140ece 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 74f1288b46dd20643a494acb1829dbe38c367622 -README.zh.md: dda46a89f3c2b161d7358109e4317a976eaa65c8 +README.md: 9fb1264f39aee3f3961ff4fd3c07f35b0b7cefdf +README.zh.md: 844254acc1f349a9debf38494e8d97f5f7e2d00b diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 74f1288b46..9fb1264f39 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider Bundle](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider, the Claude Agent SDK, nor the Codex wrapper and platform payloads. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of each base-backed profile's `dsh.profile.bundles` list. The standalone [`sdk-minimal`](../sdk-minimal/README.md) profile deliberately does not include this bundle. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider Bundle](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider, the Claude Agent SDK, nor the Codex wrapper and platform payloads. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. The base module-HMR row is disabled. A profile with a tested source-module reload lifecycle enables that row explicitly; `patchReload: live` config watching is independent and uses the launcher's watch-only fallback while module HMR remains disabled. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index dda46a89f3..844254acc1 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.zh.md) 选择、工具、持久化、策略、settings/credentials、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 仅在需要时安装任一[产品 provider Bundle](../../subagent/README.zh.md)。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider、Claude Agent SDK,也不包含 Codex wrapper 及其平台载荷。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.zh.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.zh.md) 选择、工具、持久化、策略、settings/credentials、遥测与核心 spawn/fork subagent provider——作为每个基于 base 的 profile 的 `dsh.profile.bundles` 列表中的第一层。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) profile 刻意不包含本组合包。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 仅在需要时安装任一[产品 provider Bundle](../../subagent/README.zh.md)。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider、Claude Agent SDK,也不包含 Codex wrapper 及其平台载荷。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.zh.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 base 的模块 HMR 配置项默认禁用。具有经过验证的源码模块重载生命周期的 profile 必须显式启用该配置项;`patchReload: live` 配置监视与之独立,在模块 HMR 保持禁用时使用启动器的仅监视 fallback。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e7e963e59f..65da35f554 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -1,4 +1,4 @@ -# The dsh-base bundle patch: the shared core of every dsh profile, applied as +# The dsh-base bundle patch: the shared core of each base-backed profile, applied as # ONE insert over the empty profile root. Later bundle patches and the user's # profile cordis.patch.yml address these rows by id, with the last write # winning per row. diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 2d0977a727..80f33257db 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-base", - "description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root", + "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index 51cf2a832d..715a58381e 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md -README.md: 5bc60991624ab52b3d55acac4851b6af2b198218 -README.zh.md: 7e11140bcb8a81cf3e22db88530bfcb364436f96 +README.md: 7f37d0cb4996a9c48e4bcb934565ff2865f5e7e3 +README.zh.md: 3e2c5b5f66314e8dc2da9622bbd1937d85d98490 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 5bc6099162..7f37d0cb49 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -26,6 +26,6 @@ Stable for a fixed persona, platform, provider, model, and bundle patch stack. P ## Known Limitations and Deferred Work -- **The profile is POSIX-only** — this layer uses a Bash PTY; Windows support belongs to the platform runtime layer above it. +- **The profile is POSIX-only** — this composition uses a Bash PTY; a Windows profile must select a PowerShell terminal and tool instead. - **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. - **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index 7e11140bcb..3e2c5b5f66 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -26,6 +26,6 @@ ## 已知限制与待办工作 -- **该 profile 仅支持 POSIX** — 此层使用 Bash PTY;Windows 支持属于其上的平台运行时层。 +- **该 profile 仅支持 POSIX** — 此组合使用 Bash PTY;Windows profile 必须改为选择 PowerShell 终端与工具。 - **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、权限策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 - **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index c3e1d67e92..c0c43e838d 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/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/examples/README.md -README.md: 1193ae404bb7f1a69636f45ff36eee82ec648b2c -README.zh.md: 935bbd1329082f857a692df913a89e7007054b63 +README.md: 2d17e60ed990c8cb77ebfd5823ba2f67e63e8837 +README.zh.md: deea149a11dc70a329bb27c6d851d62d46c6d16e diff --git a/packages/examples/README.md b/packages/examples/README.md index 1193ae404b..2d17e60ed9 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -8,7 +8,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling |---|---|---| | [`agent-spine-demo/`](agent-spine-demo/README.md) | `@deepseek-ai/dsh-agent-spine-demo` | Reusable agent-spine bundle | -`agent-spine-demo` is the shared bundle. Product SDK, ACP, and one-shot execution belong to `dsh --profile sdk`, `dsh --profile acp`, and `dsh --profile headless`; no package in this directory provides an application entry. +`agent-spine-demo` is the shared bundle. Product SDK, ACP, and one-shot execution belong to `dsh --profile sdk` / `dsh --profile sdk-minimal`, `dsh --profile acp`, and `dsh --profile headless`; no package in this directory provides an application entry. These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 935bbd1329..deea149a11 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | [`agent-spine-demo/`](agent-spine-demo/README.zh.md) | `@deepseek-ai/dsh-agent-spine-demo` | 可复用的 agent-spine(智能体主干)组合包 | -`agent-spine-demo` 是共享组合包。产品 SDK、ACP 与一次性执行分别由 `dsh --profile sdk`、`dsh --profile acp` 和 `dsh --profile headless` 提供;本目录没有任何包提供应用入口。 +`agent-spine-demo` 是共享组合包。产品 SDK、ACP 与一次性执行分别由 `dsh --profile sdk`/`dsh --profile sdk-minimal`、`dsh --profile acp` 和 `dsh --profile headless` 提供;本目录没有任何包提供应用入口。 这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index fee9373a2d..1076f068de 100644 --- a/python/README.i18n.yaml +++ b/python/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 python/README.md -README.md: 7cb822e475acaa36a2a1637b0b4830e7dcabcb7a -README.zh.md: e571b936dad5d14db834fcabed363b5b7c199807 +README.md: d82195fa8a3f39bea129030b2eaf7d65536041e0 +README.zh.md: cb0fb28fff7d2442c948205b07c457243fb85022 diff --git a/python/README.md b/python/README.md index 7cb822e475..d82195fa8a 100644 --- a/python/README.md +++ b/python/README.md @@ -13,7 +13,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com ## Behavior -The SDK starts the matching bundled `dsh --profile sdk` runtime unless the caller selects another `dsh` executable or profile. Every launch requires an explicitly selected Harness home; Python never silently reads `~/.dsh`. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own runtime selection, profiles, patches, and external plugin management. +The SDK starts the matching bundled `dsh --profile sdk` runtime unless the caller selects another `dsh` executable or profile. The runnable minimal example selects the shipped standalone `sdk-minimal` profile; the same runtime also packages `dsh web` and its frontend assets for separate CLI use. Every launch requires an explicitly selected Harness home; Python never silently reads `~/.dsh`. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own runtime selection, profiles, patches, and external plugin management. ## Contributor workflows diff --git a/python/README.zh.md b/python/README.zh.md index e571b936da..cb0fb28fff 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -13,7 +13,7 @@ ## 行为 -除非调用方选择另一个 `dsh` 可执行程序或 profile,否则 SDK 会启动匹配的内置 `dsh --profile sdk` 运行时。每次启动都要求显式选择 Harness home;Python 绝不会静默读取 `~/.dsh`。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义运行时选择、profile、patch 与外部插件管理约定。 +除非调用方选择另一个 `dsh` 可执行程序或 profile,否则 SDK 会启动匹配的内置 `dsh --profile sdk` 运行时。可运行极简示例选择随附的独立 `sdk-minimal` profile;同一运行时还会为独立 CLI 使用打包 `dsh web` 及其前端产物。每次启动都要求显式选择 Harness home;Python 绝不会静默读取 `~/.dsh`。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义运行时选择、profile、patch 与外部插件管理约定。 ## 贡献者工作流 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index bff1468133..a372e966ac 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 145f958a9df28e76456fb16a68b36fe318afa323 -development.zh.md: 557d6ba1f766dca61ab7bc73bf71ced2eb79afbe +development.md: 61094a277d2b91063a0d368ec30f444eeb132128 +development.zh.md: a73de6e091050cebb0b26037a7cca3adc814d961 diff --git a/python/development.md b/python/development.md index 145f958a9d..61094a277d 100644 --- a/python/development.md +++ b/python/development.md @@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` -Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the checked-in minimal composition's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job; it drops the dynamic runtime-context snapshot, which the same composition emits on macOS and not on Linux ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)). `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. +Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the shipped `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job. `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. Trusted pull requests also run `--scenario sdk-live --installed-wheel` on every native target. That scenario performs two tool-using turns against `https://api.deepseek.com`, verifies the created file externally, and fails when the repository secret is absent instead of self-skipping. Fork and Dependabot pull requests run the complete keyless installed-wheel path but receive no key. diff --git a/python/development.zh.md b/python/development.zh.md index 557d6ba1f7..a73de6e091 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` -其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定了签入的极简组合所组装的系统提示词、对外公布的工具 schema 以及模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败;它会丢弃动态运行时上下文快照——同一组合在 macOS 上会发出它,在 Linux 上不会([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 +其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定随附 `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 可信拉取请求还会在每个原生目标上运行 `--scenario sdk-live --installed-wheel`。该场景面向 `https://api.deepseek.com` 执行两个使用工具的轮次,从外部验证已创建文件,并在仓库密钥缺失时失败而不是自行 skip。Fork 与 Dependabot 拉取请求会运行完整的 keyless 安装后 wheel 路径,但不会获得密钥。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index ddcef99f9e..51965002ce 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 52103b538be0ebc79572d8477e407f324e419ce6 -README.zh.md: e4fe7673a510102504454d5a6113ec7ec6ffd086 +README.md: 1552f2a120938ecab6d05dd244a745bec65bb00a +README.zh.md: 9524617ee1a950080476a91db5ec6e14727518ce diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 52103b538b..1552f2a120 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -12,7 +12,7 @@ Production executables are named `deepseek-harness-sdk-runtime-- Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. -Both carriers execute the same `dsh` grammar and profiles. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. +Both carriers execute the same `dsh` grammar and shipped profiles, including the standalone `sdk-minimal` tree and the full `web` profile with its frontend assets. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. ## Python module API diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index e4fe7673a5..9524617ee1 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -12,7 +12,7 @@ Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模 仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 与 sdist 均不包含它。 -两种载体执行相同的 `dsh` 语法和 profile。私有 `dsh-python-runtime-closure` manifest 定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 +两种载体执行相同的 `dsh` 语法与随附 profile,包括独立的 `sdk-minimal` 配置树,以及包含前端产物的完整 `web` profile。私有 `dsh-python-runtime-closure` manifest 定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 ## Python 模块 API diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 29b3a5a70b..1e98c9d0a3 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: ef2ba1764aa93fc6499941dd372a1de94f33d454 -README.zh.md: b5c36b6ff0fbce303649ae92efd7e1edac954e82 +README.md: 4138ff5b8fb476da2d82f710f72369d3855066a2 +README.zh.md: a51777a427deb65b461213aed28bb9df179ed1b1 diff --git a/python/sdk/README.md b/python/sdk/README.md index ef2ba1764a..4138ff5b8f 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -53,12 +53,14 @@ with DeepSeekHarness( `profile` may select another existing profile, but that composition must retain `@deepseek-ai/dsh-sdk-app` or another `@deepseek-ai/dsh-sdk-jsonrpc-server` row. Misconfiguration fails during CLI boot or SDK initialization; there is no complete-config fallback. `dsh_bin` may select another `dsh` executable while preserving the same profile grammar. Arbitrary argv replacement remains an internal fake-runtime test adapter, not public API. +The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"` and set the same model in `env={"DSH_MODEL": model}` so its one adapter route matches SDK initialization. It provides persistent Bash, the string-replace editor, local execution, and JSONL sessions; settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. + ## Results and notifications `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. `HarnessClient` retains discovered subagent ancestry for the runtime process lifetime. During `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and known descendants in wire order. `RunResult.events` contains root-session events only, so descendant output cannot replace the root response. The low-level `session_prompt()` returns the queued message id immediately; callers that bypass `Session.run()` own the later activity boundary. -The selected home stores profiles, plugins, credentials, settings, and sessions. Use a fresh home when those resources must be isolated, and a fresh session id for independent work. Reusing both a harness and session id continues the durable conversation and session-owned resources. +The selected home stores profiles, plugins, and every profile-owned durable resource. The full `sdk` profile uses its credentials, settings, and session stores; `sdk-minimal` uses only its JSONL session store. Use a fresh home when those resources must be isolated, and a fresh session id for independent work. Reusing both a harness and session id continues the durable conversation and session-owned resources. See the [Python tutorial](../../docs/user/guide/python-sdk.md), [`python-sdk-agent` example](../../examples/python-sdk-agent/README.md), and [runtime wheel reference](../sdk-runtime/README.md). diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index b5c36b6ff0..a51777a427 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -53,12 +53,14 @@ with DeepSeekHarness( `profile` 可以选择另一个已存在的 profile,但该组合必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项。配置错误会在 CLI 启动或 SDK 初始化时失败;不存在完整配置回退。`dsh_bin` 可以选择另一个 `dsh` 可执行程序,同时保持相同的 profile 语法。任意 argv 替换仅是内部 fake-runtime 测试适配器,不属于公开 API。 +随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它,并在 `env={"DSH_MODEL": model}` 中设置相同模型,使其唯一适配器路由与 SDK 初始化一致。它提供持久 Bash、字符串替换 editor、本地执行与 JSONL 会话;settings、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。 + ## 结果与通知 `Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入 idle 时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的 assistant 文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 `HarnessClient` 会在运行时进程的整个生命周期内保留已发现的子 agent 祖先关系。在 `Session.run()` 期间,`RunResult.notifications` 与 `on_notification` 按协议顺序接收根会话和已知后代的通知。`RunResult.events` 只包含根会话事件,因此后代输出不会替换根响应。底层 `session_prompt()` 会立即返回已排队消息的 id;绕过 `Session.run()` 的调用方自行负责后续活动边界。 -所选 home 保存 profile、插件、凭据、设置和会话。需要隔离这些资源时应使用新的 home;独立工作应使用新的 session id。同时复用 harness 与 session id 会延续持久对话和会话资源。 +所选 home 保存 profile、插件与每个 profile 自有的持久资源。完整 `sdk` profile 使用其中的凭据、设置与会话存储;`sdk-minimal` 只使用自己的 JSONL 会话存储。需要隔离这些资源时应使用新的 home;独立工作应使用新的 session id。同时复用 harness 与 session id 会延续持久对话和会话资源。 另见 [Python 教程](../../docs/user/guide/python-sdk.zh.md)、[`python-sdk-agent` 示例](../../examples/python-sdk-agent/README.zh.md)和[运行时 wheel 参考](../sdk-runtime/README.zh.md)。 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 79407ece40..c5452d8d4a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -763,7 +763,7 @@ const APP_EXAMPLES = [ title: 'DSH Base Composition', label: 'packages/bundle/base/cordis.patch.yml', config: 'packages/bundle/base/cordis.patch.yml', - summary: 'The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user\'s profile layer patch over it.', + summary: 'The dsh-base bundle patch shared by the web, headless, sdk, and acp profiles; their mode bundles and user layers patch over it, while sdk-minimal owns a separate standalone tree.', }, { id: 'headless', From e2920f0109bb6c5f83f72569faa7c05c0a615d2d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:02:51 +0800 Subject: [PATCH 071/138] fix(sdk-minimal): make the SDK model argument authoritative Stop narrowing the standalone DeepSeek adapter to a DSH_MODEL-derived one-entry catalog. The direct adapter already accepts model ids outside its advisory catalog, so retain only the DSH_CONTEXT_WINDOW fallback and let the JSON-RPC initialize model be the single runtime selection. Remove model mirroring from minimal.py and the packaged smoke. The keyless process now initializes deepseek-v4-pro without DSH_MODEL, while the packaged scenario continues to use its unlisted smoke-model; together they prove both cataloged and arbitrary SDK model arguments reach the adapter directly. Update the bundle, tutorial, SDK/example references, and owning Agent Notes to keep DSH_MODEL only as minimal.py's optional default input, never as a second value callers must synchronize. --- .../2026-08-24-standalone-sdk-minimal-profile.i18n.yaml | 4 ++-- .../2026-08-24-standalone-sdk-minimal-profile.md | 2 +- .../2026-08-24-standalone-sdk-minimal-profile.zh.md | 2 +- ...26-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml | 4 ++-- .../2026-08-11-minimal-profiles-bare-two-tool-runtime.md | 4 ++-- .../2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md | 4 ++-- docs/user/guide/python-sdk.i18n.yaml | 4 ++-- docs/user/guide/python-sdk.md | 1 - docs/user/guide/python-sdk.zh.md | 1 - examples/python-sdk-agent/README.i18n.yaml | 4 ++-- examples/python-sdk-agent/README.md | 2 +- examples/python-sdk-agent/README.zh.md | 2 +- examples/python-sdk-agent/minimal.py | 1 - examples/python-sdk-agent/tests/keyless-smoke.e2e.ts | 1 - packages/bundle/sdk-minimal/README.i18n.yaml | 4 ++-- packages/bundle/sdk-minimal/README.md | 2 +- packages/bundle/sdk-minimal/README.zh.md | 2 +- packages/bundle/sdk-minimal/cordis.patch.yml | 4 +--- packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts | 5 +++++ python/sdk/README.i18n.yaml | 4 ++-- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- scripts/smoke-python-runtime.py | 3 --- 23 files changed, 30 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml index 6fb8fc0362..4a06faa161 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.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-24-standalone-sdk-minimal-profile.md -2026-08-24-standalone-sdk-minimal-profile.md: aa822d40a3b15bb55c36e8bcc585c4aaeb03c13d -2026-08-24-standalone-sdk-minimal-profile.zh.md: cea6ad6f575882b07d9494532e94cfe95bd61b43 +2026-08-24-standalone-sdk-minimal-profile.md: bc1a177dc4a232201004e6869caa452a7d55deb9 +2026-08-24-standalone-sdk-minimal-profile.zh.md: ae6fdb95079f748f26758a30c968c27548e9a87f diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md index aa822d40a3..bc1a177dc4 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -22,7 +22,7 @@ The bundle reuses `@deepseek-ai/dsh-sdk-app` for command help, stdin EOF, and bo ### Explicit composition -The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, persistent Bash, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. The persona comes from `DSH_SYSTEM_PROMPT`, the adapter route from `DSH_MODEL` and `DSH_CONTEXT_WINDOW`, and the credential from `DEEPSEEK_API_KEY`. +The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, persistent Bash, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. The SDK initialization request owns the model id; `DSH_CONTEXT_WINDOW` supplies fallback capacity for models outside the adapter's advisory catalog. The persona comes from `DSH_SYSTEM_PROMPT`, and the credential from `DEEPSEEK_API_KEY`. Harness identity, runtime context, workspace instructions, skills, model-facing job controls, compaction, settings, managed credentials, telemetry, Web tools, subagents, and every other base row are absent rather than hidden. The profile pins `danger-full-access`, `maxTokensAsSuccess: false`, and startup-only patch loading. This layer is POSIX-only because its persistent terminal uses Bash. diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md index cea6ad6f57..ae6fdb9507 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -22,7 +22,7 @@ Status: implemented ### 显式组合 -该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。Persona 来自 `DSH_SYSTEM_PROMPT`,适配器路由来自 `DSH_MODEL` 与 `DSH_CONTEXT_WINDOW`,凭据来自 `DEEPSEEK_API_KEY`。 +该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。SDK 初始化请求拥有模型 id;`DSH_CONTEXT_WINDOW` 为不在适配器建议目录中的模型提供后备容量。Persona 来自 `DSH_SYSTEM_PROMPT`,凭据来自 `DEEPSEEK_API_KEY`。 Harness 身份、运行时上下文、workspace 指令、skills、面向模型的 job 控制、compaction、settings、托管凭据、遥测、Web 工具、subagent 与其他所有 base 配置项均不存在,而不是被隐藏。该 profile 固定使用 `danger-full-access`、`maxTokensAsSuccess: false` 与仅启动时 patch 加载。由于持久终端使用 Bash,此层只支持 POSIX。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index 58474befe4..4a576e9339 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: dbbe0e253d23a7663bdd40ac302e779d3090e8e0 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 831b475f277beeef7e7542fb23c410311b45151b +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 6ea86832b632c631b7e02d6c486f3858fd2632a4 +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 73f6f16a51c14a7c98915a84878b39596d12f245 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index dbbe0e253d..6ea86832b6 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -16,7 +16,7 @@ Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_ The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. The [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns this bundle placement and its separation from `dsh-base`. -`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) uses `DSH_MODEL` as its default `model` argument and passes an explicit `--model` back to the child environment so the catalog and request remain aligned. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. +`DSH_SYSTEM_PROMPT` selects the standalone persona, and `DSH_CONTEXT_WINDOW` supplies fallback capacity for a model without exact catalog metadata. The SDK client's JSON-RPC `initialize` request is the sole runtime model selection. [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) may read `DSH_MODEL` only as the command's default `model` argument; an explicit `--model` needs no matching child environment value. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. ## Verification @@ -32,7 +32,7 @@ The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an **Use one Cordis leaf for Web and Python SDK startup.** Rejected because a Web preset contributes agent-scoped services to an existing multi-session host, while the Python SDK must launch a complete process containing the JSON-RPC server and its process-wide dependencies. -**Read `DSH_MODEL` only inside Cordis.** Rejected because Cordis configures the provider catalog but does not own the SDK client's JSON-RPC `initialize` request. The launcher must pass the same model to the client request for the environment value to select the routed model. +**Mirror the requested model into `DSH_MODEL`.** Rejected because the direct adapter accepts model ids outside its advisory catalog and resolves fallback context metadata for them. Mirroring creates two inputs for one selection; the SDK initialization request is authoritative, while `DSH_MODEL` remains only a convenience default in `minimal.py`. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index 831b475f27..73f6f16a51 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -16,7 +16,7 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` 独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责该组合包的位置及其与 `dsh-base` 的分离。 -`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)会使用 `DSH_MODEL` 作为 `model` 参数的默认值,并把显式 `--model` 传回子进程环境,使目录与请求保持一致。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 +`DSH_SYSTEM_PROMPT` 选择独立组合的 persona,`DSH_CONTEXT_WINDOW` 为没有确切目录元数据的模型提供后备容量。SDK 客户端的 JSON-RPC `initialize` 请求是唯一运行时模型选择。[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)可以只把 `DSH_MODEL` 读作命令的默认 `model` 参数;显式 `--model` 不需要匹配的子进程环境值。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 ## 验证 @@ -32,7 +32,7 @@ SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环 **为 Web 与 Python SDK 启动使用同一个 Cordis leaf。** 不予采用,因为 Web preset 向现有多会话宿主贡献 agent 作用域服务,而 Python SDK 必须启动包含 JSON-RPC 服务器及其进程级依赖的完整进程。 -**只在 Cordis 内读取 `DSH_MODEL`。** 不予采用,因为 Cordis 配置提供方目录,但不拥有 SDK 客户端的 JSON-RPC `initialize` 请求。launcher 必须向客户端请求传递同一个模型,环境值才能选择路由模型。 +**把请求模型镜像到 `DSH_MODEL`。** 不予采用,因为直接适配器接受不在建议目录中的模型 id,并为它们解析后备上下文元数据。镜像会为同一项选择制造两个输入;SDK 初始化请求具有权威,`DSH_MODEL` 只保留为 `minimal.py` 的便捷默认值。 ## 后果 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 88d467f519..4ddf1a6015 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/python-sdk.md -python-sdk.md: 2cf6acbd6718be560ec4365b356c031b17fb070e -python-sdk.zh.md: 2c53886ba3409e6fc21ec57fc79b81bf6907349c +python-sdk.md: 24f5594a20eab6870d9725e0f1acfce5dff62f74 +python-sdk.zh.md: 47b420ab20df04d28e5498807dc73a425c7a9666 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 2cf6acbd67..24f5594a20 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -61,7 +61,6 @@ with DeepSeekHarness( cwd=str(workspace), dsh_home=str(dsh_home), profile="sdk-minimal", - env={"DSH_MODEL": "deepseek-v4-flash"}, ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 2c53886ba3..47b420ab20 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -61,7 +61,6 @@ with DeepSeekHarness( cwd=str(workspace), dsh_home=str(dsh_home), profile="sdk-minimal", - env={"DSH_MODEL": "deepseek-v4-flash"}, ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index 0c3a5fe203..e57c422380 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-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 examples/python-sdk-agent/README.md -README.md: 5bb958c3e9202642926a0186d055615a895d9586 -README.zh.md: 97bfe9e1d19a7cd37ed41fccc64340dc054ecec7 +README.md: 46a8dc2384d96db39841c4c1e4cdc88d82ff55eb +README.zh.md: 5e6e2f89f27398dd7404894f15391a6a693d689b diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index 5bb958c3e9..46a8dc2384 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -17,7 +17,7 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` overrides the model and passes the same value to the profile-owned adapter catalog; `--profile` can select another SDK-serving profile. The selected home stores the generated `sdk-minimal` profile and uncompressed JSONL session logs under `sessions/`; the script never reads `~/.dsh` implicitly. +Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the script's default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` is the single runtime model selection; no matching environment variable is required. `--profile` can select another SDK-serving profile. The selected home stores the generated `sdk-minimal` profile and uncompressed JSONL session logs under `sessions/`; the script never reads `~/.dsh` implicitly. The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../packages/bundle/sdk-minimal/README.md) is the complete explicit Cordis tree for this mode. It exposes exactly: diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index 97bfe9e1d1..5e6e2f89f2 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -17,7 +17,7 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 会覆盖模型,并将同一值传给 profile 自有的适配器目录;`--profile` 可以选择另一个提供 SDK 服务的 profile。所选 home 保存生成的 `sdk-minimal` profile,并在 `sessions/` 下保存未压缩 JSONL 会话日志;脚本绝不会隐式读取 `~/.dsh`。 +兼容代理使用 `DEEPSEEK_BASE_URL`,脚本默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 是唯一运行时模型选择,不要求匹配的环境变量;`--profile` 可以选择另一个提供 SDK 服务的 profile。所选 home 保存生成的 `sdk-minimal` profile,并在 `sessions/` 下保存未压缩 JSONL 会话日志;脚本绝不会隐式读取 `~/.dsh`。 随附的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../packages/bundle/sdk-minimal/README.zh.md)是该模式完整且显式的 Cordis 配置树。它只暴露: diff --git a/examples/python-sdk-agent/minimal.py b/examples/python-sdk-agent/minimal.py index 86c1dc8082..28d2fd8c54 100644 --- a/examples/python-sdk-agent/minimal.py +++ b/examples/python-sdk-agent/minimal.py @@ -39,7 +39,6 @@ def main() -> None: cwd=str(workspace), dsh_home=str(dsh_home), profile=args.profile, - env={"DSH_MODEL": args.model}, ) as harness: result = harness.run(args.prompt, session_id=args.session_id) print(result.final_response) diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index e0bd4ed6c3..04eff29319 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -209,7 +209,6 @@ describe('Python SDK dsh profile keyless smoke', () => { cwd: repoRoot, env: { DSH_HOME: join(root, '.dsh'), - DSH_MODEL: 'deepseek-v4-pro', DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index 715a58381e..c573869472 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md -README.md: 7f37d0cb4996a9c48e4bcb934565ff2865f5e7e3 -README.zh.md: 3e2c5b5f66314e8dc2da9622bbd1937d85d98490 +README.md: b33ccb429ab0291d46f0271329b957f0ac7011fa +README.zh.md: 9e4ab381f5595630cbf139b1a1e8e30cef147f39 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 7f37d0cb49..b33ccb429a 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -6,7 +6,7 @@ Standalone minimal SDK application bundle for `dsh --profile sdk-minimal`. Its s The profile remains part of the ordinary launcher and layering model. The bundle supplies the complete default tree; the profile patch, home patch, and ordered `--patch` files can replace rows or insert external bundles above it. `dsh plugin --profile sdk-minimal` manages persistent dependencies. The shipped template uses startup-only patches so one stdio connection never observes replacement of its server or agent dependencies. -`DEEPSEEK_API_KEY` supplies the adapter credential. `DSH_MODEL` selects the sole configured model, `DSH_CONTEXT_WINDOW` sets its context window, and `DSH_SYSTEM_PROMPT` replaces the default persona. The process working directory is the sandbox-policy workspace and local-filesystem root. The bundle sets `danger-full-access`; its persistent shell and editor can modify any path available to the process. +`DEEPSEEK_API_KEY` supplies the adapter credential. The SDK initialization request is the sole model selection; the adapter accepts that model id even when it is absent from its advisory catalog. `DSH_CONTEXT_WINDOW` sets the fallback capacity for such models, and `DSH_SYSTEM_PROMPT` replaces the default persona. The process working directory is the sandbox-policy workspace and local-filesystem root. The bundle sets `danger-full-access`; its persistent shell and editor can modify any path available to the process. ## Model Experience diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index 3e2c5b5f66..9e4ab381f5 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -6,7 +6,7 @@ 该 profile 仍遵循普通 launcher 与分层模型。组合包提供完整默认树;profile patch、home patch 与有序 `--patch` 文件可以在其上替换配置项或插入外部组合包。`dsh plugin --profile sdk-minimal` 管理持久依赖。随附模板仅在启动时应用 patch,因此一个 stdio 连接不会观察到服务器或 agent 依赖在运行中被替换。 -`DEEPSEEK_API_KEY` 提供适配器凭据。`DSH_MODEL` 选择唯一配置的模型,`DSH_CONTEXT_WINDOW` 设置其上下文窗口,`DSH_SYSTEM_PROMPT` 替换默认 persona。进程工作目录同时作为沙箱策略 workspace 与本地文件系统根目录。该组合包设置 `danger-full-access`;其持久 shell 与编辑器可以修改进程可访问的任何路径。 +`DEEPSEEK_API_KEY` 提供适配器凭据。SDK 初始化请求是唯一模型选择;即使该模型 id 不在适配器的建议目录中,适配器也会接受它。`DSH_CONTEXT_WINDOW` 为这类模型设置后备容量,`DSH_SYSTEM_PROMPT` 替换默认 persona。进程工作目录同时作为沙箱策略 workspace 与本地文件系统根目录。该组合包设置 `danger-full-access`;其持久 shell 与编辑器可以修改进程可访问的任何路径。 ## 模型体验 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index e43f3e2b03..24d707c7e9 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -27,10 +27,8 @@ name: '@deepseek-ai/dsh-llm-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY + defaultContextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) streamIdleTimeoutMs: 172800000 - models: - - id: !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash' - contextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index 4e9fbbcc6a..f8c23e92ac 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -44,6 +44,11 @@ describe('dsh-sdk-minimal bundle', () => { inject: ['sdkAppStartup', 'loader'], config: { maxTokensAsSuccess: false }, }) + expect(rows.find(row => row.id === 'llm-deepseek')?.config).toEqual({ + apiKeyEnv: 'DEEPSEEK_API_KEY', + defaultContextWindow: { __jsExpr: 'Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000)' }, + streamIdleTimeoutMs: 172800000, + }) expect(rows.find(row => row.id === 'agent-spine')?.config).toMatchObject({ includeHarnessIdentity: false, includeRuntimeContext: false, diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 1e98c9d0a3..5295ed3c11 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: 4138ff5b8fb476da2d82f710f72369d3855066a2 -README.zh.md: a51777a427deb65b461213aed28bb9df179ed1b1 +README.md: cf9bb3e3ccac4908e9212d8f7247545b5a6b5d8e +README.zh.md: 9acb8f26129144a77a834f94854cdd3f1a200086 diff --git a/python/sdk/README.md b/python/sdk/README.md index 4138ff5b8f..cf9bb3e3cc 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -53,7 +53,7 @@ with DeepSeekHarness( `profile` may select another existing profile, but that composition must retain `@deepseek-ai/dsh-sdk-app` or another `@deepseek-ai/dsh-sdk-jsonrpc-server` row. Misconfiguration fails during CLI boot or SDK initialization; there is no complete-config fallback. `dsh_bin` may select another `dsh` executable while preserving the same profile grammar. Arbitrary argv replacement remains an internal fake-runtime test adapter, not public API. -The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"` and set the same model in `env={"DSH_MODEL": model}` so its one adapter route matches SDK initialization. It provides persistent Bash, the string-replace editor, local execution, and JSONL sessions; settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. +The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides persistent Bash, the string-replace editor, local execution, and JSONL sessions; settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. ## Results and notifications diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index a51777a427..9acb8f2612 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -53,7 +53,7 @@ with DeepSeekHarness( `profile` 可以选择另一个已存在的 profile,但该组合必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项。配置错误会在 CLI 启动或 SDK 初始化时失败;不存在完整配置回退。`dsh_bin` 可以选择另一个 `dsh` 可执行程序,同时保持相同的 profile 语法。任意 argv 替换仅是内部 fake-runtime 测试适配器,不属于公开 API。 -随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它,并在 `env={"DSH_MODEL": model}` 中设置相同模型,使其唯一适配器路由与 SDK 初始化一致。它提供持久 Bash、字符串替换 editor、本地执行与 JSONL 会话;settings、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。 +随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 id。它提供持久 Bash、字符串替换 editor、本地执行与 JSONL 会话;settings、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。 ## 结果与通知 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index ba5279000d..4a5a3eb0be 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -978,9 +978,6 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - dsh_bin=str(executable), dsh_home=str(dsh_home), profile="sdk-minimal", - env={ - "DSH_MODEL": "smoke-model", - }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, From 104fe9b9e786d695b6e27b4ad5063912d4fa4786 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:16:04 +0800 Subject: [PATCH 072/138] test(sdk-app): cover default profile configuration The startup test helper always constructed an explicit sdk profile, so coverage never exercised apply's supported default-config path even though runtime behavior depended on it.\n\nPass Config objects through the helper and default them to an empty config. The existing startup and help tests now prove sdk defaulting, while the explicit sdk-minimal case remains covered. --- packages/bundle/sdk-app/tests/startup.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bundle/sdk-app/tests/startup.spec.ts b/packages/bundle/sdk-app/tests/startup.spec.ts index 5f0ec6524a..ec128a1c4a 100644 --- a/packages/bundle/sdk-app/tests/startup.spec.ts +++ b/packages/bundle/sdk-app/tests/startup.spec.ts @@ -4,7 +4,7 @@ import { EventEmitter } from 'node:events' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' -import { apply, SDK_APP_STARTUP_SERVICE } from '../src/index.ts' +import { apply, type Config, SDK_APP_STARTUP_SERVICE } from '../src/index.ts' /** Controllable stdin for one startup invocation. */ class TestStdin extends EventEmitter { @@ -27,7 +27,7 @@ afterEach(() => { }) /** Run the provider with captured command output and exit requests. */ -function start(args: string[], profile = 'sdk'): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { +function start(args: string[], config: Config = {}): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { const ctx = new Context() const exits: number[] = [] const stdin = new TestStdin() @@ -41,7 +41,7 @@ function start(args: string[], profile = 'sdk'): { ctx: Context; exits: number[] exit: code => void exits.push(code), ready: { onReady: (listener) => { listener(); return () => {} } }, }) - apply(ctx, { profile }) + apply(ctx, config) return { ctx, exits, out: () => out, stdin } } @@ -64,7 +64,7 @@ describe('SDK app startup', () => { }) it('renders the selected SDK profile name in help', () => { - const { out } = start(['--help'], 'sdk-minimal') + const { out } = start(['--help'], { profile: 'sdk-minimal' }) expect(out()).toContain('Usage: dsh --profile sdk-minimal') expect(out()).toContain('dsh --profile sdk-minimal') }) From 4719bef9329b67da11d78a7ef94428dca8b09a66 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:27:52 +0800 Subject: [PATCH 073/138] test(python): exercise the shipped SDK profile directly The keyless max-token smoke carried a disable overlay that reproduced the removed private carrier's reduced tool roster. That legacy roster is now owned by the standalone sdk-minimal profile, so mutating the full sdk profile hides the application the SDK actually ships.\n\nLaunch sdk without a patch for both the successful and invalid-config paths. The separate sdk-minimal process test continues to pin its exact platform-selected two-tool request. --- .../tests/keyless-smoke.e2e.ts | 15 -------- .../python-sdk-agent/tests/keyless.patch.yml | 38 ------------------- 2 files changed, 53 deletions(-) delete mode 100644 examples/python-sdk-agent/tests/keyless.patch.yml diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 04eff29319..625fa2f16f 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -9,7 +9,6 @@ import { execa } from 'execa' import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) -const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -77,8 +76,6 @@ describe('Python SDK dsh profile keyless smoke', () => { binScript, '--profile', 'sdk', - '--patch', - patchPath, ], { cwd: repoRoot, env: { @@ -147,17 +144,7 @@ describe('Python SDK dsh profile keyless smoke', () => { }, }, }) - const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] expect(modelRequests[0]?.max_tokens).toBe(1234) - expect(tools.map(tool => tool.function?.name).sort()).toEqual([ - 'bash', - 'edit', - 'read', - 'read_image', - 'subagent', - 'todo_write', - 'write', - ]) child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) @@ -283,8 +270,6 @@ describe('Python SDK dsh profile keyless smoke', () => { binScript, '--profile', 'sdk', - '--patch', - patchPath, ], { cwd: repoRoot, env: { diff --git a/examples/python-sdk-agent/tests/keyless.patch.yml b/examples/python-sdk-agent/tests/keyless.patch.yml deleted file mode 100644 index 71b5ec6ebd..0000000000 --- a/examples/python-sdk-agent/tests/keyless.patch.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Preserve the focused SDK test roster over the shipped sdk profile. - -- id: agent-instructions - disabled: true -- id: tool-jobs - disabled: true -- id: tool-fs-search - disabled: true -- id: skill-filesystem - disabled: true -- id: tool-skill - disabled: true -- id: tool-str-replace-editor - disabled: true -- id: tool-subagent-control - disabled: true -- id: tool-subagent-list-agents - disabled: true -- id: tool-subagent-fork - disabled: true -- id: tool-subagent-report - disabled: true -- id: tool-workflow - disabled: true -- id: tool-goal - disabled: true -- id: plan-mode - disabled: true -- id: tool-ralph - disabled: true -- id: tool-web - disabled: true - -- id: tool-subagent - config: - provider: spawn - toolName: subagent - backgroundMode: one-shot From c27de594fd518951e1d4be16f3f0032d3220f8bc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 17:59:02 +0800 Subject: [PATCH 074/138] =?UTF-8?q?feat(web):=20=E5=9C=A8=20Trajectory=20?= =?UTF-8?q?=E4=B8=AD=E5=B1=95=E7=A4=BA=E5=9B=BE=E7=89=87=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trajectory 现在通过共享的 ui-attachment 画廊渲染会话日志中的持久化图片引用:ui-conversation 拥有按会话的图片 URL 缓存(ctx.uiConversation.imageUrl),ui-trajectory 声明 conversation.trajectory.images 子槽位,纯图片记录行以图片数量标注,内联 imageSrc 嗅探作为死代码删除。 Closes #2986 --- ...-24-trajectory-image-attachments.i18n.yaml | 6 ++ ...2026-08-24-trajectory-image-attachments.md | 33 ++++++ ...6-08-24-trajectory-image-attachments.zh.md | 33 ++++++ .../trajectory-image-display.snapshot.ts | 93 ++++++++++++++++ .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- packages/client/ui-attachment/package.json | 7 +- .../client/ui-attachment/src/client/index.ts | 5 + .../ui-attachment/tests/plugin.client.spec.ts | 8 +- packages/client/ui-attachment/tsconfig.json | 3 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- packages/client/ui-chat/package.json | 2 - packages/client/ui-chat/src/client/apply.ts | 4 +- .../ui-chat/src/client/chat/ChatView.tsx | 6 +- .../ui-chat/src/client/contract/slots.ts | 13 +-- packages/client/ui-chat/src/client/index.ts | 3 +- .../tests/image-labels.client.spec.tsx | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/slots.ts | 14 +++ .../src/client/conversation/assembly.ts | 15 +++ .../client/conversation}/historical-images.ts | 23 ++-- .../ui-conversation/src/client/index.ts | 2 +- .../tests/historical-images.client.spec.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 | 6 +- .../src/client/TrajectoryTable.module.css | 29 ----- .../src/client/TrajectoryTable.tsx | 97 +++++++++-------- .../src/client/TrajectoryView.tsx | 20 +++- .../client/ui-trajectory/src/client/index.ts | 4 + .../client/ui-trajectory/src/client/layout.ts | 72 ++++--------- .../ui-trajectory/src/client/locales.ts | 4 +- .../src/client/trajectory-contract.ts | 14 ++- .../src/client/trajectory-record.ts | 4 +- .../src/client/trajectory-search-index.ts | 2 +- .../tests/layout.client.spec.tsx | 87 +++++++++++++++ .../ui-trajectory/tests/table.client.spec.tsx | 102 +++++++++++++++++- .../ui-trajectory/tests/views.client.spec.tsx | 4 + packages/client/ui-trajectory/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 90 +++++++++++----- pnpm-lock.yaml | 9 +- 47 files changed, 631 insertions(+), 226 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md create mode 100644 .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md create mode 100644 apps/web/tests/trajectory-image-display.snapshot.ts rename packages/client/{ui-chat/src/client => ui-conversation/src/client/conversation}/historical-images.ts (80%) rename packages/client/{ui-chat => ui-conversation}/tests/historical-images.client.spec.ts (79%) diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml new file mode 100644 index 0000000000..b82bec2f49 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.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-24-trajectory-image-attachments.md +2026-08-24-trajectory-image-attachments.md: 4b79ecbb113a0f36b4cf31f9b0c68cb1fc013d13 +2026-08-24-trajectory-image-attachments.zh.md: f7695ab322f80699f1f424d26d179415f0181efc diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md new file mode 100644 index 0000000000..4b79ecbb11 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md @@ -0,0 +1,33 @@ +# Agent Note: Trajectory durable image attachments + +Status: implemented + +English | [中文](2026-08-24-trajectory-image-attachments.zh.md) + +## Problem + +Trajectory did not display session images. A durable `{ type: 'image', attachment: ImageAttachmentRef }` block rendered as pretty-printed JSON in the details panel, and an image-only user message produced an empty ledger row. The only image path Trajectory knew was `imageSrc` sniffing over inline wire fields (`url`, `image_url`, base64 `data`), which no production event carries: every producer commits a durable `ImageAttachmentRef` before its event is appended. Users could not confirm from the execution ledger which image the model saw ([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)), while Chat already displayed the same attachments. + +## Decision + +- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. +- The gallery owner contract (`MessageImagesOwnerProps`, `RenderMessageImages`) moved to the `ui-conversation` client contract. `ui-chat` keeps its `conversation.message.images` SlotMap row over the shared owner type; `ui-trajectory` declares its own child slot `conversation.trajectory.images` with the same owner type; `ui-attachment` registers the one `MessageImages` gallery component into both keys, so loading, retry, and lightbox behavior is identical in both views. +- `TrajectorySourceBlock` carries `attachment?: ImageAttachmentRef` instead of `imageSrc`/`imageAlt`. The inline-source sniffing (`sourceImage`, `safeImageSource`) and the Trajectory-local `PanelImage` renderer are removed: no producer writes inline image bytes or URLs into the session log, so those paths were dead code, and the issue explicitly excludes upload-time transient paths. +- A record whose content has images but no text labels its ledger row with the locale-owned `layout.imageOnly` count; tool results with only images use the same label for their result summary instead of a JSON dump. +- Neither the storage nor the BFF changes: `session.attachment` already authorizes by session-log reference (missing, corrupt, and unreferenced attachments fail loud into the gallery's retry state), and sha256 content addressing already stores each image once. + +## Alternatives considered + +**Keep Trajectory's own `` rendering and feed it resolved URLs.** This duplicates the loading placeholder, retry control, and lightbox that `ui-attachment` already owns, and contradicts [slot-based attachment ownership](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md), which rejected cross-plugin component imports. + +**Lift the `conversation.message.images` declaration to a shared parent so both views render one key.** `renderSlot` is typed to the declaring entry's own children table, so a sibling `conversation.view` entry cannot render another entry's child key; the slot registry also rejects a second declaration of the same key. A second key sharing the owner type is the supported composition and lets a theme replace either gallery independently. + +**Keep the inline `imageSrc` sniffing beside the durable path.** All producers (host prompt admission, `read_image`, MCP projection, ACP ingress) commit durable refs before their events append, so the sniffing matched nothing; keeping it would preserve a non-durable rendering path the acceptance criteria exclude. + +**A Trajectory-owned image cache.** A second cache per view issues duplicate `session.attachment` RPCs and duplicate blob URLs for the same session attachment, violating the "Chat and Trajectory reference the same session attachment" requirement for no benefit. + +## Consequences + +- Both views present one gallery implementation, so image behavior (sizing, retry, lightbox, labels) cannot drift between Chat and Trajectory, and a session attachment is read once regardless of how many views show it. +- `TrajectoryTable` threads a required `renderImages` prop through its detail components; `ui-trajectory` gains a type-only dependency on `dsh-attachment`, and `ui-attachment` gains a type-only dependency on `ui-trajectory` for the new SlotMap row. +- The keyless assembled snapshot `apps/web/tests/trajectory-image-display.snapshot.ts` pins the shared-cache fact directly: the details-panel image URL is string-identical to the Chat gallery's URL for the same fixture attachment. diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md new file mode 100644 index 0000000000..f7695ab322 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Trajectory 持久化图片附件 + +Status: implemented + +[English](2026-08-24-trajectory-image-attachments.md) | 中文 + +## Problem + +Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: ImageAttachmentRef }` 块在详情面板里渲染成格式化 JSON,纯图片的用户消息在记录表中是一个空行。Trajectory 唯一认识的图片路径是对内联 wire 字段(`url`、`image_url`、base64 `data`)的 `imageSrc` 嗅探,而生产事件从不携带这些字段:每个生产方都在事件追加前提交持久化的 `ImageAttachmentRef`。用户无法从执行记录确认模型看到了哪张图([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)),而 Chat 已经能展示同样的附件。 + +## Decision + +- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。 +- 画廊 owner 契约(`MessageImagesOwnerProps`、`RenderMessageImages`)移入 `ui-conversation` 客户端契约。`ui-chat` 的 `conversation.message.images` SlotMap 行沿用共享 owner 类型;`ui-trajectory` 以同一 owner 类型声明自己的子槽位 `conversation.trajectory.images`;`ui-attachment` 把同一个 `MessageImages` 画廊组件注册进两个键,因此加载、重试与灯箱行为在两个视图中完全一致。 +- `TrajectorySourceBlock` 以 `attachment?: ImageAttachmentRef` 取代 `imageSrc`/`imageAlt`。内联来源嗅探(`sourceImage`、`safeImageSource`)与 Trajectory 本地的 `PanelImage` 渲染器一并删除:没有生产方向会话日志写入内联图片字节或 URL,这些路径是死代码,且 issue 明确排除上传来源的临时路径。 +- 内容含图片但没有文本的记录,其记录表行以 locale 持有的 `layout.imageOnly` 计数标注;只含图片的工具结果的摘要也使用同一标签,而不是 JSON 转储。 +- 存储与 BFF 均不改动:`session.attachment` 已按会话日志引用授权(缺失、损坏与未被引用的附件显式失败并进入画廊的重试态),sha256 内容寻址已保证每张图片只存一份。 + +## Alternatives considered + +**保留 Trajectory 自己的 `` 渲染并喂给它解析好的 URL。** 这会重复 `ui-attachment` 已拥有的加载占位、重试控件和灯箱,并与[基于 slot 的附件所有权](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md)相抵触,该决定已拒绝跨插件直接 import 组件。 + +**把 `conversation.message.images` 的声明上提到共享父级,让两个视图渲染同一个键。** `renderSlot` 的类型限定在声明入口自己的 children 表内,同级的 `conversation.view` 入口无法渲染另一个入口的子键;slot registry 也拒绝对同一键的第二次声明。共享 owner 类型的第二个键是受支持的组合方式,且允许主题独立替换任一画廊。 + +**在持久化路径之外保留内联 `imageSrc` 嗅探。** 所有生产方(宿主 prompt admission、`read_image`、MCP 投影、ACP 入口)都在事件追加前提交持久化引用,嗅探不会命中任何东西;保留它等于保留验收标准明确排除的非持久化渲染路径。 + +**Trajectory 自有的图片缓存。** 每个视图一份缓存会对同一会话附件发出重复的 `session.attachment` RPC 和重复的 blob URL,违背"Chat 与 Trajectory 引用同一会话附件"的要求,且没有任何收益。 + +## Consequences + +- 两个视图共用一个画廊实现,图片行为(尺寸、重试、灯箱、文案)不会在 Chat 与 Trajectory 之间漂移,且无论多少个视图展示,一个会话附件只读取一次。 +- `TrajectoryTable` 需要把必填的 `renderImages` prop 逐层传入详情组件;`ui-trajectory` 新增对 `dsh-attachment` 的仅类型依赖,`ui-attachment` 为新的 SlotMap 行新增对 `ui-trajectory` 的仅类型依赖。 +- keyless 组装快照 `apps/web/tests/trajectory-image-display.snapshot.ts` 直接钉住共享缓存这一事实:详情面板中的图片 URL 与 Chat 画廊对同一 fixture 附件的 URL 字符串相同。 diff --git a/apps/web/tests/trajectory-image-display.snapshot.ts b/apps/web/tests/trajectory-image-display.snapshot.ts new file mode 100644 index 0000000000..90659cf2b5 --- /dev/null +++ b/apps/web/tests/trajectory-image-display.snapshot.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +// Trajectory image surfaces over the BUILT client graph (the code-mode-fixture +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session whose turn 73 carries an image in BOTH a +// user message and an assistant message, and pins the Trajectory surfaces: +// selecting the ledger record renders the shared ui-attachment gallery from +// the durable session-log reference, and the browser URL is the SAME object +// URL Chat resolved — one sessions.attachment read per session attachment. +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { expect, it, vi } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +/** Open the fixture history session and wait for the Chat gallery to load. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('fixture')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group === null || group === undefined) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(group.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0) + }, { timeout: 10_000 }) +} + +/** Scroll the virtual ledger until the row whose text contains `needle` mounts. */ +async function scrollRowIntoWindow(needle: string): Promise { + await waitFor(() => { + if (document.querySelectorAll('tr[data-trajectory-row-key]').length === 0) { + throw new Error('trajectory rows not mounted') + } + }, { timeout: 10_000 }) + const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement + if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing') + for (let top = 0; top <= 40_000; top += 1_000) { + pane.scrollTop = top + fireEvent.scroll(pane) + // Let the virtualizer publish the new window before probing. + await new Promise(resolve => setTimeout(resolve, 25)) + const hit = [...document.querySelectorAll('tr[data-trajectory-row-key]')] + .find(row => row.textContent?.includes(needle)) + if (hit !== undefined) return hit + } + throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`) +} + +it('renders durable record images in the Trajectory details panel from the shared cache', async () => { + // The virtual ledger needs a measurable viewport; jsdom reports zero + // heights, so pin one and neutralize the imperative tail scroll. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: () => {}, + }) + mountAssembledApp() + await openFixtureSession() + const chatSrc = document.querySelector('[data-align="end"] img')?.getAttribute('src') + if (chatSrc === null || chatSrc === undefined) throw new Error('chat gallery image missing') + + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + const userRow = await scrollRowIntoWindow('历史用户图片') + fireEvent.click(userRow) + + // Selecting the record opens the details panel; the ui-attachment gallery + // resolves the durable reference through the SAME per-session cache Chat + // used, so the object URL is identical — no second attachment read. + const panel = await screen.findByRole('tabpanel') + await waitFor(() => { + expect(within(panel).getAllByRole('img').length).toBeGreaterThan(0) + }, { timeout: 10_000 }) + expect(within(panel).getAllByRole('img').map(img => ({ + alt: img.getAttribute('alt'), + scheme: img.getAttribute('src')?.split(':')[0], + sharedWithChat: img.getAttribute('src') === chatSrc, + }))).toMatchInlineSnapshot(` + [ + { + "alt": "fixture-image.png", + "scheme": "blob", + "sharedWithChat": true, + }, + ] + `) +}) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 4bbc3c3dcd..40aab703eb 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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-attachment/README.md -README.md: 925187da23e9acec9a69959bfc29ce7ec62f0096 -README.zh.md: 2872dac19e36143923ba39e4b3efbd6e8bb27cac +README.md: ee9b604d0227b5ae52f833f97f80429e4ab93bea +README.zh.md: 8d8cd43c32587f258de3e6a26fa7e0d0127dbf57 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 925187da23..ee9b604d02 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Dynamic attachment presentation plugin for the conversation UI. It waits for the conversation package's `conversation.input.attachments` and `conversation.message.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, chat-history image gallery, and original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry. +Dynamic attachment presentation plugin for the conversation UI. It waits for the `conversation.input.attachments`, `conversation.message.images`, and `conversation.trajectory.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, the history image gallery serving both the Chat transcript and the Trajectory inspector, and the original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry. ## Attachment rail diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 2872dac19e..8d8cd43c32 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 conversation 包声明 `conversation.input.attachments` 与 `conversation.message.images`,随后注册输入框草稿图片栏、文档拖放目标、聊天历史图片画廊和原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。 +对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images` 与 `conversation.trajectory.images` 声明,随后注册输入框草稿图片栏、文档拖放目标、同时服务 Chat 会话记录与 Trajectory 检查器的历史图片画廊,以及原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。 ## 附件栏 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 71ba3edf76..d5a10b446b 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", - "description": "Dynamic attachment presentation plugin for conversation input and message-image slots", + "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -34,7 +34,8 @@ "inject": [ "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-renderer" + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-trajectory" ], "platform": "web" } @@ -54,6 +55,7 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -72,6 +74,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^" } diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index bcdbfb7adf..8fe94f64ee 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -3,6 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client' import { ComposerAttachments } from './ComposerAttachments.tsx' import { MessageImages } from './MessageImages.tsx' @@ -19,4 +20,8 @@ export function apply(ctx: ClientContext): void { name: 'conversation.message.images', locale: 'conversation', }, MessageImages)) + ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({ + name: 'conversation.trajectory.images', + locale: 'conversation', + }, MessageImages)) } diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 9ca742377f..21d84953de 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -14,6 +14,7 @@ async function bench() { children: { 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, 'conversation.message.images': { kind: 'single', scope: 'session' }, + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) const fiber = ctx.plugin({ inject: [...inject], apply }) @@ -26,7 +27,7 @@ describe('attachment plugin', () => { expect(() => { applyHost() }).not.toThrow() }) - it('registers both entries and removes them with the plugin fiber', async () => { + it('registers all entries and removes them with the plugin fiber', async () => { const { ctx, fiber } = await bench() expect(inject).toEqual(['slots']) expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ @@ -37,10 +38,15 @@ describe('attachment plugin', () => { locale: 'conversation', component: MessageImages, }]) + expect(ctx.slots.entries('conversation.trajectory.images')).toMatchObject([{ + locale: 'conversation', + component: MessageImages, + }]) await fiber.dispose() expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0) expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0) + expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0) }) }) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 0cd20ee366..b2783b20e9 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-trajectory" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4abebdd3f3..4859153e08 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 56bb20ab7b4d9b5c0c95142b311a07ad9b8a1fd3 -README.zh.md: 40ee1ee710e2f86e802ffaac4dc0bb10852f128f +README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288 +README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 56bb20ab7b..5253cb95b0 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, historical images, and scroll restoration. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). ## Model Experience diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 40ee1ee710..8cd2d0d581 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化、历史图片与滚动位置恢复。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。 ## 模型体验 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 186afe1f55..b29c675a08 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -72,7 +72,6 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { @@ -101,7 +100,6 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 1e0730d740..3243c6c642 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -23,7 +23,6 @@ import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' import { StatsLine } from './chat/StatsLine.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' import { DetailsPanel } from './details/DetailsPanel.tsx' -import { HistoricalImageCache } from './historical-images.ts' import { en, NS, zh } from './locale.ts' import { createChatStore } from './stores.ts' @@ -74,7 +73,6 @@ export function apply(ctx: Context): void { const t = ctx.locale.bind(NS) const chatStore = createChatStore() const chatScrollPositions = new Map() - const images = new HistoricalImageCache(ctx) ctx.slots.inject('conversation.view', () => { const disposeView = ctx.slots.register({ @@ -102,7 +100,7 @@ export function apply(ctx: Context): void { return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path)) }, loadOlder: () => { void session.loadOlder() }, - loadImage: attachment => images.resolve(sessionId, attachment), + loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), chatScroll: { save: (position) => { if (position === null) chatScrollPositions.delete(sessionId) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 88c2f66f68..a647ad5c18 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -2,9 +2,11 @@ // otherwise this view owns it. Each row subscribes to one stable node key. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationTimelineSnapshot, RenderMessageImages, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 5009781fd1..cff42849eb 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,9 +1,8 @@ /** Chat-owned Slot declarations and composed component props. */ -import type { ReactNode } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationTurnDataMap, TurnLocation, + ConversationTurnDataMap, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, @@ -19,16 +18,6 @@ import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook -/** Historical image group handed to the optional attachment presentation plugin. */ -export interface MessageImagesOwnerProps { - images: readonly { readonly attachment: ImageAttachmentRef }[] - loadImage: (attachment: ImageAttachmentRef) => Promise - align: 'start' | 'end' -} - -/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */ -export type RenderMessageImages = (owner: Omit) => ReactNode - /** Owner currency of the completed-Turn extension chain. */ export interface TurnTailOwnerProps { turn: TurnLocation diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index a847b039fd..c04c2c588e 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -28,8 +28,7 @@ export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps, - DetailsToolOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, - TurnTailOwnerProps, UseChat, UseChatNodeTurnData, + DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, } from './contract/slots.ts' export type { ChatKey } from './locale.ts' export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts' diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index b260850bc3..bb5d339f0a 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -5,8 +5,8 @@ import { cleanup, render } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import type { RenderMessageImages } from '../src/client/contract/slots.ts' import { zh } from '../src/client/locale.ts' afterEach(cleanup) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a6102ddac0..20b50fa39b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 4c9665b680fe1922770403d88a04dffc755481ad -README.zh.md: 8bf3db2cb1401427f29016f8dbddcd9d27ec9635 +README.md: 14d2665abea44f395e758deaf527bfe767e0fff6 +README.zh.md: ae118eb2f07db9ee7ee464ee9b467721c20254e7 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4c9665b680..14d2665abe 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. +`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. ## Conversation assembly diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8bf3db2cb1..ae118eb2f0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。 +`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。 ## Conversation 组装 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 57c686383b..a7865d5906 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,5 +1,6 @@ /** Target-neutral Conversation slot declarations and composed component props. */ import type { ReactNode, RefObject } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { @@ -43,6 +44,19 @@ export interface ComposerAttachmentsOwnerProps { dropLimits?: { readonly count: number; readonly size: string } | undefined } +/** Durable image group handed to the optional attachment presentation plugin. */ +export interface MessageImagesOwnerProps { + /** Durable image references in source order. */ + images: readonly { readonly attachment: ImageAttachmentRef }[] + /** Session-authorized image URL loader. */ + loadImage: (attachment: ImageAttachmentRef) => Promise + /** Horizontal placement inside the owning record. */ + align: 'start' | 'end' +} + +/** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ +export type RenderMessageImages = (owner: Omit) => ReactNode + /** Selector hook over the current Session's assembled Conversation. */ export type UseConversation = SnapshotSelectorHook /** Selector hook over the registered Conversation View roster. */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 58ea1bf79d..26801e161b 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -1,5 +1,6 @@ /** Per-Session target-neutral Conversation assembly. */ import { Service, type Context } from '@deepseek-ai/cordis' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ISessions, SessionBinding, SessionEventSource, SessionEventWindow, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -15,6 +16,7 @@ import type { import type { ConversationSnapshot } from '../contract/snapshot.ts' import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' +import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ @@ -144,6 +146,7 @@ export class UiConversation extends Service { /** Registry of target View definitions. */ readonly views: ConversationViewRegistry private readonly bindings = new Map() + private readonly images: HistoricalImageCache /** * @param ctx - owning Client context. @@ -153,6 +156,7 @@ export class UiConversation extends Service { super(ctx, 'uiConversation') this.events = new ConversationEventRegistry(ctx) this.views = new ConversationViewRegistry(ctx) + this.images = new HistoricalImageCache(ctx, sessions) const rebuild = (): void => { for (const record of this.bindings.values()) record.binding.rebuild() } @@ -202,6 +206,17 @@ export class UiConversation extends Service { return binding } + /** + * Resolve one session-authorized durable image URL, cached per Session so + * every Conversation target shares one read and one browser URL. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference from a session event. + * @returns browser URL valid until the Session binding is released. + */ + imageUrl(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + return this.images.resolve(sessionId, attachment) + } + private drop(record: BindingRecord, releaseScope: boolean): void { if (this.bindings.get(record.source.sessionId) !== record) return this.bindings.delete(record.source.sessionId) diff --git a/packages/client/ui-chat/src/client/historical-images.ts b/packages/client/ui-conversation/src/client/conversation/historical-images.ts similarity index 80% rename from packages/client/ui-chat/src/client/historical-images.ts rename to packages/client/ui-conversation/src/client/conversation/historical-images.ts index 1e53e78bb8..602b104c1d 100644 --- a/packages/client/ui-chat/src/client/historical-images.ts +++ b/packages/client/ui-conversation/src/client/conversation/historical-images.ts @@ -1,4 +1,4 @@ -/** Session-scoped historical image URL cache owned by the Chat plugin. */ +/** Session-scoped durable image URL cache shared by Conversation targets. */ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' @@ -11,9 +11,8 @@ interface ImageUrlEntry { readonly pending: Promise } -/** Resolve durable Chat images and release their browser URLs with Session scope. */ +/** Resolve durable Conversation images and release their browser URLs with Session scope. */ export class HistoricalImageCache { - private readonly sessions: ISessions private readonly entries = new Map() private readonly generations = new Map() private readonly scopeDisposers = new Map void>() @@ -21,11 +20,11 @@ export class HistoricalImageCache { private disposed = false /** - * @param ctx - Owning ui-chat fiber. + * @param ctx - Owning ui-conversation fiber. + * @param sessions - Session Controller object layer. */ - constructor(ctx: Context) { - this.sessions = ctx.sessions - ctx.effect(() => () => { this.dispose() }, 'ui-chat historical image cache') + constructor(ctx: Context, private readonly sessions: ISessions) { + ctx.effect(() => () => { this.dispose() }, 'ui-conversation historical image cache') } /** @@ -35,22 +34,22 @@ export class HistoricalImageCache { * @returns browser URL valid until the Session binding is released. */ resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { - if (this.disposed) return Promise.reject(new Error('ui-chat image cache is disposed')) + if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed')) const key = `${sessionId}:${attachment.attachmentId}` const cached = this.entries.get(key) if (cached !== undefined) return cached.pending const binding = this.sessions.binding(sessionId) if (binding === undefined) { - return Promise.reject(new Error(`ui-chat: unknown session "${sessionId}"`)) + return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`)) } this.bindScope(sessionId, binding.ctx) const generation = this.generations.get(sessionId) ?? 0 const pending = binding.session.readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) - if (this.disposed) throw new Error('ui-chat image cache was disposed before loading completed') + if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed') if ((this.generations.get(sessionId) ?? 0) !== generation) { - throw new Error('ui-chat image scope was released before loading completed') + throw new Error('ui-conversation image scope was released before loading completed') } if (typeof URL.createObjectURL !== 'function') { return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` @@ -73,7 +72,7 @@ export class HistoricalImageCache { const dispose = scope.effect(() => () => { this.scopeDisposers.delete(sessionId) this.release(sessionId) - }, 'ui-chat historical image scope') + }, 'ui-conversation historical image scope') this.scopeDisposers.set(sessionId, () => { void dispose() }) } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 0d6874f5a3..966135c043 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -51,7 +51,7 @@ export type { ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, - UseConversation, UseConversationViews, + MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews, } from './contract/slots.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, diff --git a/packages/client/ui-chat/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts similarity index 79% rename from packages/client/ui-chat/tests/historical-images.client.spec.ts rename to packages/client/ui-conversation/tests/historical-images.client.spec.ts index 1642923a31..71aa3d6018 100644 --- a/packages/client/ui-chat/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import { HistoricalImageCache } from '../src/client/historical-images.ts' +import { HistoricalImageCache } from '../src/client/conversation/historical-images.ts' describe('HistoricalImageCache', () => { it('invalidates a pending image load when its Session binding is released', async () => { @@ -13,7 +13,7 @@ describe('HistoricalImageCache', () => { id: 's1', session: { readAttachment: () => read.promise }, }) - const cache = new HistoricalImageCache(runtime.ctx) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) const attachment = { attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, } as const @@ -22,7 +22,7 @@ describe('HistoricalImageCache', () => { await runtime.sessions.remove(sessionId) read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) - await expect(pending).rejects.toThrow('ui-chat image scope was released before loading completed') + await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed') await runtime.dispose() }) }) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 47b35ae17d..96f48c6888 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: 97badd562bbf132c9766c763ff604306d1a6c08d -README.zh.md: 5a17ee811047e8ffd15be849595d87adfe4ddf00 +README.md: c623105b9bb84edbd8ff6a91244271f6fc92d943 +README.zh.md: 007f4451be9ac2f19c7fdd8e6eefe410813c7eb7 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 97badd562b..c623105b9b 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. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that 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 loaded ledger. 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 assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. 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. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. +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. Durable image attachments in user input, assistant output, and tool results render through the `conversation.trajectory.images` gallery slot: a record without text labels its row with the image count, the inspector shows each image with the shared loading, retry, and lightbox behavior, and image URLs come from the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. 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. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that 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 loaded ledger. 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 assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. 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. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 5a17ee8110..007f4451be 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 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。用户输入、助手输出和工具结果中的持久化图片附件通过 `conversation.trajectory.images` 画廊 slot 渲染:没有文本的记录行以图片数量标注,检查器内展示每张图片并复用共享的加载、重试与灯箱行为,图片 URL 来自 Conversation 持有的按会话缓存,因此 Chat 与 Trajectory 对同一附件共享一次经会话授权的读取。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 5f4fa4981d..054dc9ddf9 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -62,7 +62,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -85,7 +86,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 71d0b3031b..4602f925af 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1545,35 +1545,6 @@ white-space: pre-wrap; } -.panelImageLink { - display: block; - width: auto; - max-width: 100%; - overflow: hidden; - border-radius: 4px; - cursor: zoom-in; -} - -.panelImageLinkPreview { - max-height: 140px; -} - -.panelImage { - display: block; - width: auto; - max-width: 100%; - height: auto; - max-height: 320px; - margin: 0; - border-radius: inherit; - background: var(--dsw-alias-bg-base); - object-fit: contain; -} - -.panelImageLinkPreview .panelImage { - max-height: 140px; -} - .messageImages { display: flex; flex-direction: column; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index b3f5f3415e..703675795e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -14,8 +14,9 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { - AssistantRequestConfig, ConversationPromptSnapshot, + AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, @@ -377,6 +378,8 @@ function AssistantTimingPanel({ export interface TrajectoryTableProps { /** Trajectory locale seat. */ t: TrajectoryTranslate + /** Slot-backed durable image renderer shared with the Chat gallery. */ + renderImages: RenderMessageImages /** Session-global request numbers for the request groups visible in this context. */ requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ @@ -1121,10 +1124,12 @@ function MarkdownFragment({ function SourceBlocks({ blocks, onOpenCall, + renderImages, t, }: { blocks: readonly TrajectorySourceBlock[] onOpenCall: (callId: string) => void + renderImages: RenderMessageImages t: TrajectoryTranslate }) { return ( @@ -1155,8 +1160,8 @@ function SourceBlocks({ )} - {block.imageSrc !== undefined - ? + {block.attachment !== undefined + ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' }) :
{block.content}
} ))} @@ -1164,47 +1169,27 @@ function SourceBlocks({ ) } -function PanelImage({ - block, - preview = false, - t, -}: { - block: TrajectorySourceBlock - preview?: boolean - t: TrajectoryTranslate -}) { - if (block.imageSrc === undefined) return null - return ( - - {block.imageAlt - - ) +function recordImages( + blocks: readonly TrajectorySourceBlock[] | undefined, +): { readonly attachment: ImageAttachmentRef }[] { + return (blocks ?? []).flatMap(block => + block.attachment !== undefined ? [{ attachment: block.attachment }] : []) } function MessageImages({ blocks, preview, - t, + renderImages, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean - t: TrajectoryTranslate + renderImages: RenderMessageImages }) { - const images = blocks?.filter(block => block.imageSrc !== undefined) ?? [] + const images = recordImages(blocks) if (images.length === 0) return null return (
- {images.map((block, index) => )} + {renderImages({ images, align: 'start' })}
) } @@ -1404,12 +1389,12 @@ function ToolOutputBlocks({ blocks, error, preview, - t, + renderImages, }: { blocks: readonly TrajectorySourceBlock[] error: boolean preview: boolean - t: TrajectoryTranslate + renderImages: RenderMessageImages }) { return (
value !== undefined).join(' ')} > {blocks.map((block, index) => ( - block.imageSrc !== undefined - ? + block.attachment !== undefined + ? ( +
+ {renderImages({ images: [{ attachment: block.attachment }], align: 'start' })} +
+ ) : block.content !== '' ?
{block.content}
: null @@ -1436,6 +1425,7 @@ function MarkdownRecordContent({ thinkingExpanded, onThinkingExpandedChange, onOpenCall, + renderImages, t, }: { record: TableRecord @@ -1444,10 +1434,18 @@ function MarkdownRecordContent({ thinkingExpanded: boolean onThinkingExpandedChange: (expanded: boolean) => void onOpenCall: (callId: string) => void + renderImages: RenderMessageImages t: TrajectoryTranslate }) { if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { - return + return ( + + ) } if (record.cell.thinkingDetail) { if (!rendered) { @@ -1502,13 +1500,13 @@ function MarkdownRecordContent({
) } const source = markdownSource(record) - const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true + const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true const hasToolCalls = record.cell.kind === 'message' && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true if (!source && !hasImages && !hasToolCalls) { @@ -1531,7 +1529,7 @@ function MarkdownRecordContent({ t={t} /> )} - + ) } @@ -1590,11 +1588,13 @@ function RecordPayload({ record, direction, preview = false, + renderImages, t, }: { record: TableRecord direction: 'input' | 'output' preview?: boolean + renderImages: RenderMessageImages t: TrajectoryTranslate }) { const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail @@ -1624,14 +1624,14 @@ function RecordPayload({ if ( direction === 'output' && record.cell.outputBlocks?.some(block => - block.imageSrc !== undefined || block.content !== '') === true + block.attachment !== undefined || block.content !== '') === true ) { return ( ) } @@ -1791,6 +1791,7 @@ function OverviewSection({ */ export function TrajectoryTable({ t, + renderImages, requestNumbers: sessionRequestNumbers, turns, streamingCells = [], @@ -2990,6 +2991,7 @@ export function TrajectoryTable({ > { activateTab('rendered') }}> {selected.cell.inputDetail && ( { activateTab('input') }}> - + )} {selected.cell.outputDetail && ( { activateTab('output') }}> - + )} { activateTab('schema') }}> @@ -3151,6 +3154,7 @@ export function TrajectoryTable({ {!promptSelected && selected !== undefined && activeTab === 'rendered' && ( )} {!promptSelected && selected !== undefined && activeTab === 'input' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 1e36f4431b..8b982b1649 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,10 +1,11 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { - AssistantBlock, AssistantMessageNode, ConvViewProps, + AssistantBlock, AssistantMessageNode, ConvViewProps, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { TrajectoryTable, @@ -69,6 +70,7 @@ export interface TrajectoryViewInjected { duration: SnapshotStore } loadOlder: () => Promise + loadImage: (attachment: ImageAttachmentRef) => Promise setActualDuration: (actualDuration: boolean) => void } @@ -117,10 +119,17 @@ function addUsage( } export function TrajectoryView({ - useSession, useTrajectory, useDuration, loadOlder, setActualDuration, - viewRequest, completeViewRequest, t, -}: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { + useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration, + viewRequest, completeViewRequest, renderSlot, t, +}: ConvViewProps + & PropsRenderSlots<'conversation.trajectory.images'> + & InjectFace + & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) + const renderImages = useCallback( + owner => renderSlot('conversation.trajectory.images', { ...owner, loadImage }), + [loadImage, renderSlot], + ) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) const [timelineSelection, setTimelineSelection] = useState(null) @@ -481,6 +490,7 @@ export function TrajectoryView({
t('view.trajectory'), + children: { + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, + }, inject: (sessionId: SessionId): TrajectoryViewInjected => { const session = ctx.sessions.binding(sessionId)?.session if (session === undefined) { @@ -92,6 +95,7 @@ export function apply(ctx: Context): void { await session.loadOlder() return trajectory.getSnapshot() !== before }, + loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), setActualDuration: (value) => { duration.set(value) }, } }, diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 88aa5c0cbf..0283f4fde3 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -12,6 +12,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { TrajectoryCellProps, TrajectorySourceBlock, @@ -108,7 +109,7 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number { : entry.seq } -function inputCellDetail(node: InputNode): Pick< +function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< TrajectoryCellProps, | 'text' | 'previewMarkdown' @@ -120,8 +121,11 @@ function inputCellDetail(node: InputNode): Pick< | 'startedAt' > { const previewMarkdown = previewContent(node.content) + const images = imageBlockCount(node.content) return { - text: '', + text: previewMarkdown === undefined && images > 0 + ? t('layout.imageOnly', { count: images }) + : '', ...(previewMarkdown === undefined ? {} : { previewMarkdown }), sourceSeq: node.seq, messageSource: node.source, @@ -368,7 +372,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'user', - ...inputCellDetail(node), + ...inputCellDetail(node, t), opensTurn: true, }, }) @@ -387,7 +391,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'user' as const, - ...inputCellDetail(node), + ...inputCellDetail(node, t), }, } if (placement.step === undefined) pushMessage(placement.turn, laid) @@ -415,7 +419,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'context', - ...inputCellDetail(node), + ...inputCellDetail(node, t), }, }) prevAbsTime = finiteTime(node.time) ?? prevAbsTime @@ -788,6 +792,8 @@ function summarizeAssistantActivity( if (tools.size > 0) { return t('layout.toolCallOnly') } + const images = imageBlockCount(blocks.map(block => ({ type: block.kind }))) + if (images > 0) return t('layout.imageOnly', { count: images }) return '' } @@ -808,12 +814,7 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { callId: block.callId, toolName: block.name, } - // Attachment refs carry no fetchable bytes, so the record shows the - // durable metadata instead of an inline preview. - case 'image': return { - type: 'image', - content: stringifySourceValue(block.attachment), - } + case 'image': return { type: 'image', content: '', attachment: block.attachment } case 'other': return sourceBlock(block.block) } } @@ -827,47 +828,16 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { if (typeof block.text === 'string') { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } - const imageSrc = sourceImage(block) - const imageAlt = typeof block.alt === 'string' ? block.alt : undefined - return { - type, - content: imageSrc === undefined ? stringifySourceValue(value) : '', - ...(imageSrc !== undefined ? { imageSrc } : {}), - ...(imageAlt !== undefined ? { imageAlt } : {}), + if (type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + // Typed content only reaches here as a core ImageBlock; wire-shaped + // 'other' blocks never define `attachment`. + return { type, content: '', attachment: block.attachment as ImageAttachmentRef } } + return { type, content: stringifySourceValue(value) } } -function sourceImage(block: Record): string | undefined { - if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined - for (const candidate of [block.url, block.image_url]) { - if (typeof candidate === 'string') return safeImageSource(candidate) - } - if (typeof block.data === 'string') { - const mediaType = [block.mimeType, block.mediaType, block.media_type] - .find((candidate): candidate is string => typeof candidate === 'string') - ?? 'image/png' - return safeImageSource( - block.data.startsWith('data:') - ? block.data - : `data:${mediaType};base64,${block.data}`, - ) - } - if (typeof block.source !== 'object' || block.source === null) return undefined - const source = block.source as Record - if (typeof source.url === 'string') return safeImageSource(source.url) - if (typeof source.data !== 'string') return undefined - const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png' - return safeImageSource(`data:${mediaType};base64,${source.data}`) -} - -function safeImageSource(value: string): string | undefined { - if (value.startsWith('data:image/') || value.startsWith('blob:')) return value - try { - const protocol = new URL(value).protocol - return protocol === 'http:' || protocol === 'https:' ? value : undefined - } catch { - return undefined - } +function imageBlockCount(content: readonly { type: string }[]): number { + return content.filter(block => block.type === 'image').length } function stringifySourceValue(value: unknown): string { @@ -1087,6 +1057,8 @@ function summarizeResult( return { result: '', resultPreviewMarkdown: block.text } } } + const images = imageBlockCount(node.content) + if (images > 0) return { result: t('layout.imageOnly', { count: images }) } return { result: t('record.noOutput') } } @@ -1112,6 +1084,8 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { .map(block => block.type === 'text' ? block.text : '') .join('\n') if (text !== '') return text + const images = imageBlockCount(node.content) + if (images > 0) return t('layout.imageOnly', { count: images }) if ( node.content.length === 0 || node.content.every(block => diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 70707741a3..ddc5cea7c7 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -121,7 +121,6 @@ export const zh = { 'block.openSummary': '打开第 {index} 个块的工具调用概述', 'block.openSummaryTitle': '打开工具调用概述', 'block.label': '块 #{index} {type}', - 'block.openImage': '打开图片', 'history.loadingTrajectory': '正在加载轨迹…', 'history.loadingEarlier': '正在加载更早的历史…', 'history.loadingEarlierAria': '正在加载更早的历史…', @@ -174,6 +173,7 @@ export const zh = { 'layout.compactionFailed': '上下文压缩失败', 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', + 'layout.imageOnly': '图片 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', 'layout.toolsUpdated': '工具已更新', @@ -313,7 +313,6 @@ export const en: Record = { 'block.openSummary': 'Open Block #{index} tool call summary', 'block.openSummaryTitle': 'Open tool call summary', 'block.label': 'Block #{index} {type}', - 'block.openImage': 'Open image', 'history.loadingTrajectory': 'Loading trajectory…', 'history.loadingEarlier': 'Loading earlier history…', 'history.loadingEarlierAria': 'Loading earlier history…', @@ -366,6 +365,7 @@ export const en: Record = { 'layout.compactionFailed': 'Compaction failed', 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', + 'layout.imageOnly': 'Images ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', 'layout.toolsUpdated': 'Tools Updated', diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts index 5a96479bee..37571f2b83 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -1,7 +1,7 @@ import type { AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot, - ConversationViewNode, PartialAssistant, RequestPromptChange, RequestView, RunningToolCall, - ToolCallBlock, + ConversationViewNode, MessageImagesOwnerProps, PartialAssistant, RequestPromptChange, + RequestView, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -84,4 +84,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Selector hook over the current Conversation binding's Trajectory target. */ useTrajectory: UseTrajectory } + + interface SlotMap { + /** + * Renderer for one group of durable record images in the Trajectory + * ledger. The owner supplies image references, an authorized loader, and + * alignment. A registration replaces the shipped gallery; without one, + * images are omitted. + */ + 'conversation.trajectory.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + } } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 43fa1b24a8..da330f2ae4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -1,6 +1,7 @@ /** Shared trajectory record data and formatting contracts. */ import type { HTMLAttributes } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryTranslate } from './locales.ts' @@ -28,8 +29,7 @@ export interface AssistantMetricDetail { export interface TrajectorySourceBlock { type: string content: string - imageSrc?: string - imageAlt?: string + attachment?: ImageAttachmentRef callId?: string toolName?: string } diff --git a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts index 93dddb6856..9641fce1f9 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts @@ -64,7 +64,7 @@ function recordSources( block.content, block.callId ?? '', block.toolName ?? '', - block.imageAlt ?? '', + block.attachment?.name ?? '', ]), searchableJson(cell.messageSource), searchableJson(cell.promptDetail), diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index 6df422e064..bd834cbdb0 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -566,3 +566,90 @@ describe('run_code sub-dispatch cells', () => { expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4]) }) }) + +describe('durable image attachments', () => { + const attachment = { + attachmentId: `sha256:${'a'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + name: 'screenshot.png', + } + + it('carries user image refs into sourceBlocks and labels an image-only record', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', attachment }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('Images ×2') + expect(user?.previewMarkdown).toBeUndefined() + expect(user?.sourceBlocks).toEqual([ + { type: 'image', content: '', attachment }, + { type: 'image', content: '', attachment }, + ]) + }) + + it('keeps the text preview when a user message mixes text and images', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'text', text: 'look at this' }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('') + expect(user?.previewMarkdown).toBe('look at this') + expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment }) + }) + + it('maps assistant image blocks to attachment source blocks and labels image-only output', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 0, + blocks: [{ kind: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') + expect(message?.text).toBe('Images ×1') + expect(message?.sourceBlocks).toEqual([{ type: 'image', content: '', attachment }]) + }) + + it('carries tool-result image refs into outputBlocks and labels the result', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, + blocks: [{ kind: 'tool-call', callId: 'c1', name: 'read_image', argsRaw: '{}' }], + }, + { + kind: 'tool-result', seq: 2, time: 2_000, callId: 'c1', + call: { name: 'read_image', argsRaw: '{}' }, callTime: 1_200, + content: [{ type: 'image', attachment }], isError: false, + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') + expect(tool?.result).toBe('Images ×1') + expect(tool?.outputDetail).toBe('Images ×1') + expect(tool?.outputBlocks).toEqual([{ type: 'image', content: '', attachment }]) + }) + + it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', url: 'https://example.com/a.png' }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const block = turns[0]?.groups[0]?.cells[0]?.sourceBlocks?.[0] + expect(block?.attachment).toBeUndefined() + expect(block?.content).toContain('https://example.com/a.png') + }) +}) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 7e3b4bee2c..3d3955dcb3 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -4,12 +4,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ComponentProps } from 'react' +import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' import type { TrajectoryTurnModel } from '../src/client/layout.ts' import { trajectoryRecordId } from '../src/client/trajectory-record.ts' import { t, tZh } from './locale.client.ts' -function TrajectoryTable(props: Omit, 't'>) { +const renderImagesStub: RenderMessageImages = ({ images }) => ( +
+ {images.map((image, index) => ( + + ))} +
+) + +function TrajectoryTable( + props: Omit, 't' | 'renderImages'> + & { renderImages?: RenderMessageImages }, +) { const inferred: Array[number] & { firstIndex: number }> = [] for (const turn of props.turns) { for (const group of turn.groups) { @@ -40,7 +52,14 @@ function TrajectoryTable(props: Omit left.firstIndex - right.firstIndex) .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 })) - return + return ( + + ) } afterEach(() => { @@ -129,6 +148,7 @@ describe('TrajectoryTable', () => { render( ()} onToggleTurn={() => {}} @@ -937,6 +957,84 @@ describe('TrajectoryTable', () => { expect(screen.getByText('value:')).toBeTruthy() }) + it('renders user image attachments through the shared gallery in the details panel', () => { + const attachment = { + attachmentId: `sha256:${'a'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + name: 'screenshot.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Message', + cells: [{ + index: 1, + kind: 'user', + text: 'Images ×2', + sourceBlocks: [ + { type: 'image', content: '', attachment }, + { type: 'image', content: '', attachment }, + ], + timeSeconds: 0, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /USER/ })) + + const preview = screen.getAllByTestId('record-images') + expect(preview.length).toBeGreaterThan(0) + expect(preview[0]?.getAttribute('data-count')).toBe('2') + + fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) + const rawGalleries = screen.getAllByTestId('record-images') + expect(rawGalleries).toHaveLength(2) + expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) + .toBe(String(attachment.attachmentId)) + }) + + it('renders a tool-result image through the shared gallery in the Result tab', () => { + const attachment = { + attachmentId: `sha256:${'b'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 320, + height: 640, + name: 'capture.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'read_image {"path":"a.png"}', + outputDetail: 'Images ×1', + outputBlocks: [{ type: 'image', content: '', attachment }], + timeSeconds: 0.1, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Result' })) + + const gallery = screen.getAllByTestId('record-images').at(-1) + expect(gallery?.getAttribute('data-count')).toBe('1') + expect(gallery?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) + .toBe(String(attachment.attachmentId)) + }) + it('keeps the first row and a compact summary when a turn is collapsed', () => { render( {}, completeViewRequest: () => {}, + // Image seats the outlet would bake: standalone renders omit the gallery. + renderSlot: () => null, + SessionProvider: ({ children }) => <>{children}, + loadImage: () => Promise.reject(new Error('standalone views load no images')), // The locale seat the outlet would inject for the declared namespace. t: tZh, } diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 8bd120c437..0d0f85fe1d 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../attachment/attachment" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e038b24a0d..c9f2019f62 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:186', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:185', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:174', }, { key: 'conversation.chat.node', @@ -310,7 +310,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:166', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:155', }, { key: 'conversation.chat.turnTail', @@ -355,7 +355,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:191', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:180', }, { key: 'conversation.composer', @@ -404,7 +404,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92', }, { key: 'conversation.composer.bar', @@ -440,7 +440,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:110', }, { key: 'conversation.composer.dock', @@ -498,7 +498,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104', }, { key: 'conversation.details.tool', @@ -534,7 +534,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:203', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:192', }, { key: 'conversation.hero.agentPreset', @@ -562,7 +562,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98', }, { key: 'conversation.hero.brand.mark', @@ -590,7 +590,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:82', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96', }, { key: 'conversation.hero.workspace', @@ -620,7 +620,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:80', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -686,7 +686,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:112', }, { key: 'conversation.input.dock', @@ -746,7 +746,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:86', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100', }, { key: 'conversation.input.left', @@ -802,7 +802,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106', }, { key: 'conversation.input.model', @@ -838,7 +838,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120', }, { key: 'conversation.input.overlay', @@ -892,7 +892,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:88', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:102', }, { key: 'conversation.input.plan', @@ -928,7 +928,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:118', }, { key: 'conversation.input.right', @@ -984,7 +984,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:108', }, { key: 'conversation.message.images', @@ -994,7 +994,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n loadImage: (attachment: ImageAttachmentRef) => Promise\n align: \'start\' | \'end\'\n}', + '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable image references in source order. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized image URL loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', ], ownerPropsReferences: [ 'ImageAttachmentRef', @@ -1022,7 +1022,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:179', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:168', }, { key: 'conversation.session', @@ -1056,7 +1056,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:54', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:68', }, { key: 'conversation.session.header', @@ -1090,7 +1090,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:56', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70', }, { key: 'conversation.session.header.actions', @@ -1146,7 +1146,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:64', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78', }, { key: 'conversation.session.header.lineage', @@ -1184,7 +1184,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:58', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:72', }, { key: 'conversation.session.header.utilities', @@ -1239,7 +1239,45 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84', + }, + { + key: 'conversation.trajectory.images', + kind: 'single', + scope: 'session', + summary: 'Renderer for one group of durable record images in the Trajectory ledger.', + doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', + registerOptions: [], + ownerProps: [ + '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable image references in source order. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized image URL loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + ], + ownerPropsReferences: [ + 'ImageAttachmentRef', + ], + standardProps: [ + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.view\' (client-ui-trajectory), so it exists while that entry is mounted', + occupants: [ + 'client-ui-attachment MessageImages', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.trajectory.images\', () => ctx.slots.register(\n { name: \'conversation.trajectory.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-trajectory/src/client/trajectory-contract.ts:95', }, { key: 'conversation.view', @@ -1297,7 +1335,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:76', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90', }, { key: 'details', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aef169a41..ba335be6bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2035,6 +2035,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-trajectory': + specifier: workspace:^ + version: link:../ui-trajectory '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -2161,9 +2164,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-util-crypto': - specifier: workspace:^ - version: link:../../util/crypto '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path @@ -3579,6 +3579,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From f76a225a7db1560e1ed8b77d30fe4f2e7b774d65 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 24 Aug 2026 18:23:42 +0800 Subject: [PATCH 075/138] Merge pull request #2663 from deepseek-harness/feat/subagent-provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 subagent 按需发现并选择子 Agent 模型 --- ...8-10-fork-children-stay-one-shot.i18n.yaml | 4 +- .../2026-08-10-fork-children-stay-one-shot.md | 20 +- ...26-08-10-fork-children-stay-one-shot.zh.md | 20 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 6 +- .../2026-06-21-subagent-capability-seam.zh.md | 6 +- ...8-model-selected-subagent-routes.i18n.yaml | 6 + ...26-08-18-model-selected-subagent-routes.md | 61 ++ ...08-18-model-selected-subagent-routes.zh.md | 61 ++ apps/cli/tests/web-agent-presets.e2e.ts | 29 + .../models-settings/configured.expected.md | 4 + .../models-settings/declared-edit.expected.md | 4 + .../models-settings/declared.expected.md | 4 + .../models-settings/empty.expected.md | 4 + .../models.expected.md | 4 + .../dismissed.expected.md | 4 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 6 +- docs/capability-seams.zh.md | 6 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 12 +- docs/config-catalog.zh.md | 10 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 34 +- docs/event-producer-consumer.zh.md | 16 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 336 +++++------ docs/module-graph.zh.md | 338 +++++------ docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 18 +- docs/persistence-catalog.zh.md | 18 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 8 +- docs/subsystems/core.zh.md | 8 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 27 +- docs/subsystems/subagent.zh.md | 27 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 40 +- docs/tool-catalog.zh.md | 40 +- examples/acp-agent/cordis.yml | 8 + .../acp-agent/depth-two.cordis.snapshot.yml | 1 + examples/acp-agent/depth-two.cordis.yml | 1 + ...gent-configured-effort.cordis.snapshot.yml | 65 +++ .../subagent-configured-effort.cordis.yml | 15 + examples/acp-agent/tests/acp.snapshot.ts | 25 +- .../advanced-toolchain/session.1.jsonl | 4 +- .../advanced-toolchain/session.2.jsonl | 4 +- .../system-prompt.expected.md | 16 +- .../tool-schemas.expected.json | 31 +- .../both-mode-turn/system-prompt.expected.md | 16 +- .../both-mode-turn/tool-schemas.expected.json | 31 +- .../system-prompt.expected.md | 16 +- .../code-mode-turn/system-prompt.expected.md | 16 +- .../tool-schemas.expected.json | 31 +- .../lsp-definition/tool-schemas.expected.json | 31 +- .../tool-schemas.expected.json | 31 +- .../tool-schemas.expected.json | 31 +- .../tool-schemas.expected.json | 31 +- .../pty-tools/tool-schemas.expected.json | 31 +- .../tool-schemas.expected.json | 31 +- .../session.1.jsonl | 2 +- .../tool-schemas.expected.json | 31 +- .../input.json | 7 + .../replay.override.json | 32 + .../session.jsonl | 41 ++ .../stdout.expected.jsonl | 8 + .../session.1.jsonl | 2 +- .../tool-schemas.1.expected.json | 31 +- .../subagent-continuable/session.1.jsonl | 2 +- .../tool-schemas.1.expected.json | 31 +- .../session.1.jsonl | 4 +- .../session.2.jsonl | 4 +- .../subagent-fork-in-process/session.1.jsonl | 2 +- .../subagent-list-agents/session.1.jsonl | 2 +- .../tool-schemas.1.expected.json | 31 +- .../session.1.jsonl | 2 +- .../snapshots/subagent-mixed/session.1.jsonl | 4 +- .../snapshots/subagent-mixed/session.2.jsonl | 4 +- .../snapshots/subagent-multi/session.1.jsonl | 4 +- .../snapshots/subagent-multi/session.2.jsonl | 4 +- .../subagent-parallel/session.1.jsonl | 10 +- .../subagent-parallel/session.2.jsonl | 10 +- .../snapshots/subagent-report/session.1.jsonl | 2 +- .../tool-schemas.1.expected.json | 31 +- .../subagent-spawn-in-process/session.1.jsonl | 2 +- .../text-turn/tool-schemas.expected.json | 31 +- .../web-fetch/tool-schemas.expected.json | 31 +- .../snapshots/workflow-run/session.1.jsonl | 2 +- examples/headless-agent/cordis.yml | 12 +- .../advanced-toolchain/session.1.jsonl | 12 +- .../advanced-toolchain/session.2.jsonl | 12 +- .../advanced-toolchain/session.jsonl | 28 +- .../compaction-recovery/session.jsonl | 14 +- .../tests/snapshots/pty-tools/session.jsonl | 34 +- .../subagent-settlement/child.expected.jsonl | 2 +- .../parent-override/child.expected.jsonl | 4 +- .../parent-override/parent.expected.jsonl | 9 +- .../tests/subagent-inheritance.snapshot.ts | 21 +- examples/python-sdk-agent/cordis.yml | 1 + .../tests/keyless-smoke.e2e.ts | 2 + .../notifications.expected.jsonl | 2 +- .../subagent-spawn-in-process/session.1.jsonl | 2 +- .../tests/session-cold.host.spec.ts | 7 +- packages/bundle/base/cordis.patch.yml | 11 +- packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../src/client/ModelsSection.module.css | 84 ++- .../src/client/ModelsSection.tsx | 13 + .../src/client/SubagentModelSelectionCard.tsx | 87 +++ .../ui-settings-models/src/client/locales.ts | 8 + .../ui-settings-models/src/client/store.ts | 9 + .../tests/components.client.spec.tsx | 81 ++- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 3 +- packages/core/agent-loop/README.zh.md | 3 +- packages/core/agent-loop/src/agent.ts | 3 +- packages/core/agent-loop/src/index.ts | 3 +- packages/core/agent-loop/tests/loop.spec.ts | 41 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/runtime-types.ts | 4 +- .../core/session/src/known-event-types.ts | 1 + .../core/tools/tests/gen-tool-catalog.spec.ts | 4 +- .../preview-architecture-review/session.jsonl | 2 +- .../preview-follow-up-builder/session.jsonl | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 21 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 4 +- packages/llm/llm-deepseek/src/adapter.ts | 30 +- packages/llm/llm-deepseek/src/index.ts | 14 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 66 ++- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- packages/preset/agent-presets/package.json | 1 + .../presets/code/agent.cordis.yml | 5 + .../presets/cordis/agent.cordis.yml | 5 + .../presets/standard/agent.cordis.yml | 5 + packages/preset/agent-presets/src/index.ts | 14 +- .../preset/agent-presets/tests/mount.spec.ts | 21 + packages/preset/agent-presets/tsconfig.json | 3 + .../server/tests/built-scope-carrier.e2e.ts | 2 +- packages/sdk/server/tests/server.spec.ts | 10 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 6 +- packages/subagent/subagent-acp/README.zh.md | 6 +- packages/subagent/subagent-acp/src/index.ts | 12 +- .../subagent-acp/tests/subagent-acp.spec.ts | 8 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 4 +- .../subagent-claude-code/README.zh.md | 4 +- .../tests/loader-composition.e2e.ts | 4 + .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 4 +- packages/subagent/subagent-codex/README.zh.md | 4 +- .../tests/loader-composition.e2e.ts | 3 + .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 6 +- .../subagent/subagent-dsh-sdk/README.zh.md | 6 +- .../subagent/subagent-dsh-sdk/src/index.ts | 2 +- .../tests/subagent-dsh-sdk.spec.ts | 1 + .../subagent-fork-in-process/README.i18n.yaml | 4 +- .../subagent-fork-in-process/README.md | 7 +- .../subagent-fork-in-process/README.zh.md | 7 +- .../subagent-fork-in-process/src/index.ts | 23 +- .../tests/subagent-fork-in-process.spec.ts | 10 +- .../README.i18n.yaml | 4 +- .../subagent-in-process-driver/README.md | 2 +- .../subagent-in-process-driver/README.zh.md | 2 +- .../tests/structured.spec.ts | 2 +- .../README.i18n.yaml | 4 +- .../subagent-spawn-in-process/README.md | 8 +- .../subagent-spawn-in-process/README.zh.md | 8 +- .../subagent-spawn-in-process/src/index.ts | 14 +- .../tests/subagent-spawn-in-process.spec.ts | 10 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 7 +- packages/subagent/subagent/README.zh.md | 7 +- packages/subagent/subagent/src/child-agent.ts | 49 +- .../subagent/subagent/src/continuation.ts | 14 +- packages/subagent/subagent/src/descriptor.ts | 11 +- packages/subagent/subagent/src/index.ts | 2 + .../subagent/subagent/src/out-of-process.ts | 3 +- packages/subagent/subagent/src/types.ts | 7 + .../subagent/tests/child-agent.spec.ts | 76 +++ .../subagent/tests/continuation.spec.ts | 66 ++- .../subagent/subagent/tests/invariant.spec.ts | 2 +- .../subagent/tests/out-of-process.spec.ts | 8 +- .../subagent/subagent/tests/service.spec.ts | 16 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 33 +- packages/subagent/tool-subagent/README.zh.md | 33 +- packages/subagent/tool-subagent/package.json | 10 + packages/subagent/tool-subagent/src/index.ts | 545 ++++++++++++------ .../subagent/tool-subagent/src/invariant.ts | 26 +- .../subagent/tool-subagent/src/list-models.ts | 94 +++ .../src/model-selection-settings.ts | 70 +++ .../src/model-selection-state.ts | 33 ++ .../tool-subagent/src/model-selection.ts | 112 ++++ .../subagent/tool-subagent/tests/harness.ts | 57 ++ .../tool-subagent/tests/list-models.spec.ts | 191 ++++++ .../tests/model-selection-settings.spec.ts | 248 ++++++++ .../tests/model-selection.spec.ts | 365 ++++++++++++ .../tool-subagent/tests/scripted-provider.ts | 1 + .../tool-subagent/tests/tool-subagent.spec.ts | 169 +++--- packages/subagent/tool-subagent/tsconfig.json | 9 + .../subagent/tool-subagent/tsdown.config.ts | 19 + .../tool-ralph/tests/tool-ralph.spec.ts | 1 + .../tool-workflow/tests/tool-workflow.spec.ts | 2 +- .../tests/built-worker.e2e.ts | 2 +- .../tests/source-worker.compat.spec.ts | 2 +- .../tests/workflow-worker-thread.spec.ts | 20 +- pnpm-lock.yaml | 9 + scripts/check-workspace-constraints.ts | 3 + scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 8 + scripts/gen-tool-catalog.ts | 15 +- .../advanced/result.json | 14 +- .../advanced/session.1.jsonl | 4 +- .../advanced/session.2.jsonl | 4 +- tsconfig.base.json | 1 + 227 files changed, 4353 insertions(+), 1029 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md create mode 100644 examples/acp-agent/subagent-configured-effort.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-configured-effort.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/stdout.expected.jsonl create mode 100644 packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx create mode 100644 packages/subagent/subagent/tests/child-agent.spec.ts create mode 100644 packages/subagent/tool-subagent/src/list-models.ts create mode 100644 packages/subagent/tool-subagent/src/model-selection-settings.ts create mode 100644 packages/subagent/tool-subagent/src/model-selection-state.ts create mode 100644 packages/subagent/tool-subagent/src/model-selection.ts create mode 100644 packages/subagent/tool-subagent/tests/harness.ts create mode 100644 packages/subagent/tool-subagent/tests/list-models.spec.ts create mode 100644 packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts create mode 100644 packages/subagent/tool-subagent/tests/model-selection.spec.ts create mode 100644 packages/subagent/tool-subagent/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml index 7c2052c0a3..7342dd6abc 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md -2026-08-10-fork-children-stay-one-shot.md: 44b947a3e0580263f1973aaf24534b7b2f01c0b6 -2026-08-10-fork-children-stay-one-shot.zh.md: acb12c54fa37d4462cac1b1035bc74d5a96ea719 +2026-08-10-fork-children-stay-one-shot.md: b2d9a77d7cc9969e517a4f5d6973aa2aee1134f5 +2026-08-10-fork-children-stay-one-shot.zh.md: b5dc1a7fda4e2b4152baf63e9c49b89bcebdeef0 diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md index 44b947a3e0..b2d9a77d7c 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md @@ -1,4 +1,4 @@ -# Agent Note: Forked children stay one-shot +# Agent Note: Cache-preserving forked children stay one-shot Status: implemented @@ -12,7 +12,7 @@ The child-scoped `report` return channel is now the largest such addition, and s ## 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. +The cache-preserving compositions bind 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. The standard, code, and Cordis CLI presets instead bind fork to `continuable`; their child-scoped `report` additions invalidate the inherited prefix and accept the recomputation cost described here. One-shot children — foreground and background alike — are created through `SubagentRuntime.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. @@ -20,9 +20,9 @@ One-shot children — foreground and background alike — are created through `S ### The restriction is composition, not code -`ForkInProcessProvider.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. +`ForkInProcessProvider.prepareContinuable` stays implemented and `ctx.subagents.startContinuable()` accepts `fork`; composition chooses whether the fork tool is one-shot or continuable. `tool-subagent` knows both the provider's `inheritsParentContext` and its own `backgroundMode` at mount, so a load-time rejection of the pair is available and deliberately absent: the pair is not wrong in general. It is costly 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. +The cache-preserving condition is recorded as a `TODO(fork-continuable-prefix-reuse)` marker on `prepareContinuable` and tracked as issue #2124: continuable fork preserves its inherited prefix when the child's system prompt and tool schemas can match the parent's byte for byte. ## Alternatives considered @@ -30,7 +30,7 @@ The reintroduction condition is recorded as a `TODO(fork-continuable-prefix-reus **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`. +**Use continuable forked children in cache-preserving compositions and accept the loss.** Rejected for the base bundle and ACP/headless examples 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. The CLI presets make the other tradeoff and retain continuable fork. 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. @@ -38,12 +38,12 @@ The reintroduction condition is recorded as a `TODO(fork-continuable-prefix-reus ## 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. +- The base bundle and ACP/headless examples create only one-shot forked children; their `subagent_fork` returns a result to the caller's turn, and `send_message` addresses only spawned children there. The three CLI presets create continuable forked children. +- A one-shot forked child's request prefix stays byte-identical to its parent's unless the deployment configures `persona`, `toolFilter`, or a different LLM route on the fork delegation tool, so the token cost of seeding can buy provider-side reuse. Continuable fork adds `report` before the inherited history and forfeits that reuse. +- The fork provider's continuable path has CLI production callers and package-level tests. The same seam accepts one-shot composition, so a bundle or `--patch` overlay can choose either lifecycle without a code change or 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 `next-step` scheduling, authority model, and coverage remain independent of fork composition. +- The report obligation reaches spawned children in every continuable composition and forked children in the CLI presets. Its default `next-step` scheduling, authority model, and coverage remain independent of fork composition. ### 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`. +The one-shot constraint lives in three configuration files and a code comment, not in a gate; the CLI preset rows already choose `backgroundMode: continuable` and incur the prefix loss. Any bundle or profile patch can make either choice without a warning. 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 index acb12c54fa..b5dc1a7fda 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md @@ -1,4 +1,4 @@ -# Agent Note: fork 出的 child 保持 one-shot +# Agent Note: 保留缓存的 fork child 保持 one-shot Status: implemented @@ -12,7 +12,7 @@ fork 与 spawn 的唯一区别是 child 的 Session 会以 parent 已完成轮 ## 决策 -所有随附组合都把 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` 服务而失败。 +保留缓存的组合会把 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` 服务而失败。standard、code 与 Cordis CLI preset 则把 fork 绑定为 `continuable`;其子级作用域的 `report` 增量会使继承前缀失效,并接受这里说明的重算成本。 one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` 创建,该路径从不进入可继续的 activation setup 注册表,因此 `report` 与它的提示词 section 都不会被安装。于是一个 fork 出的 one-shot child 的系统提示词与工具 schema 与其 parent 相同,只差部署逐个委派工具主动选择的 `persona` 与 `toolFilter` 增量。 @@ -20,9 +20,9 @@ one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` ### 该限制在于组合,不在于代码 -`ForkInProcessProvider.prepareContinuable` 仍然实现完好,`ctx.subagents.startContinuable()` 也仍接受 `fork`;改动的只有随附的 `cordis.yml` 行。`tool-subagent` 在挂载时同时知道提供方的 `inheritsParentContext` 与自身的 `backgroundMode`,因此一个加载期拒绝该组合的检查是可行的,而这里刻意不加:该组合并非普遍错误。它只在某个 child 作用域增量位于继承历史之前时才是错的,而产生该增量的包——[`dsh-tool-subagent-report`](../../../../packages/subagent/tool-subagent-report/README.zh.md)——是独立安装的,并且按其自身设计对 `tool-subagent` 不可见。一个不安装 report 包的部署可以在前缀完好的前提下运行可继续的 fork child。把某一份插件清单的后果写成委派工具的不变量,会让该工具断言它无法观察到的事实。 +`ForkInProcessProvider.prepareContinuable` 仍然实现完好,`ctx.subagents.startContinuable()` 也接受 `fork`;组合会选择 fork 工具采用 one-shot 还是 continuable。`tool-subagent` 在挂载时同时知道提供方的 `inheritsParentContext` 与自身的 `backgroundMode`,因此一个加载期拒绝该组合的检查是可行的,而这里刻意不加:该组合并非普遍错误。只有在某个 child 作用域增量位于继承历史之前时,它才会产生高昂成本,而产生该增量的包——[`dsh-tool-subagent-report`](../../../../packages/subagent/tool-subagent-report/README.zh.md)——是独立安装的,并且按其自身设计对 `tool-subagent` 不可见。一个不安装 report 包的部署可以在前缀完好的前提下运行可继续的 fork child。把某一份插件清单的后果写成委派工具的不变量,会让该工具断言它无法观察到的事实。 -重新开放的条件记录为 `prepareContinuable` 方法上的 `TODO(fork-continuable-prefix-reuse)` 标记——随附组合不调用这个方法——并由 issue #2124 跟踪:当 child 的系统提示词与工具 schema 能与其 parent 逐字节一致时,可继续 fork 即可重新开放。 +保留缓存的条件记录为 `prepareContinuable` 方法上的 `TODO(fork-continuable-prefix-reuse)` 标记,并由 issue #2124 跟踪:当 child 的系统提示词与工具 schema 能与其 parent 逐字节一致时,可继续 fork 就能保留继承前缀。 ## 备选方案 @@ -30,7 +30,7 @@ one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` **干脆不挂载 fork 提供方。** 这是该限制更彻底的形式。否决的原因是前台 fork *正是*复用前缀的那种情形,且不受 report 通道影响,因此全面禁用会在不换来任何 one-shot 绑定尚未换来的东西的同时放弃该能力——并且随附组合将没有任何一个演练 session 初始内容。 -**照常随附可继续的 fork child 并接受这份损失。** 否决的原因是这份损失是全额而非边际的:复用在继承历史之前就已中断,于是 child 为一份自己复制过来、目的恰恰是不必付费的 transcript 付了全额预填充。想要一个没有继承上下文的长期 child 的部署,本来就有 `spawn`。 +**在保留缓存的组合中随附可继续的 fork child 并接受这份损失。** base 组合包与 ACP/headless 示例不采用,因为这份损失是全额而非边际的:复用在继承历史之前就已中断,于是 child 为一份自己复制过来、目的恰恰是不必付费的 transcript 付了全额预填充。CLI preset 选择了另一项取舍并保留可继续 fork。想要一个没有继承上下文的长期 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.zh.md)已经定下的作用域局部决策。 @@ -38,12 +38,12 @@ one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` ## 后果 -- 没有任何随附组合会创建可继续的 fork child;`subagent_fork` 把结果返回给调用方的轮次,而 `send_message` 只寻址 spawn 出的 child。 -- 除非部署在 fork 委派工具上配置了 `persona` 或 `toolFilter`,fork child 的请求前缀与其 parent 逐字节相同,因此初始内容的 token 成本重新换来了提供方侧的复用。 -- fork 提供方的可继续路径没有生产调用方,也没有整体组装层面的覆盖。它保留自己的包内测试,seam 也仍然接受它,因此某个组合包或 `--patch` 覆盖层可以无需改动代码、也不会有任何警告地把它重新引入。 +- base 组合包与 ACP/headless 示例只创建 one-shot fork child;其中的 `subagent_fork` 会把结果返回给调用方的轮次,`send_message` 也只寻址 spawn 出的 child。三个 CLI preset 会创建可继续的 fork child。 +- 除非部署在 fork 委派工具上配置了 `persona`、`toolFilter` 或不同的 LLM 路由,one-shot fork child 的请求前缀会与其 parent 逐字节相同,因此初始内容的 token 成本可以换来提供方侧的复用。可继续 fork 会在继承历史之前增加 `report`,从而失去该复用。 +- fork 提供方的可继续路径有 CLI 生产调用方与包内测试。同一条 seam 也接受 one-shot 组合,因此某个组合包或 `--patch` 覆盖层可以无需改动代码、也不会有任何警告地选择任一生命周期。 - `subagent_fork` 面向模型的 schema 发生变化:base 组合包中可继续的后台措辞被 one-shot 的 task 措辞取代,在两个示例中则完全消失。受影响的无密钥快照工具 schema 伴随文件在同一次改动中重新记录。 -- 在随附部署中,report 义务的覆盖范围收窄到 spawn 出的 child。它的 `next-step` 默认调度、权限模型与覆盖仍独立于 fork 组合。 +- 在每个可继续组合中,report 义务都会覆盖 spawn 出的 child;在 CLI preset 中,它也覆盖 fork 出的 child。它的 `next-step` 默认调度、权限模型与覆盖仍独立于 fork 组合。 ### 已接受的风险 -该限制存在于三个配置文件与一处代码注释中,而不在门禁里。未来某个组合包行或 profile 补丁可以在 fork 工具上设置 `backgroundMode: continuable`,从而悄然重新引入前缀损失;没有任何东西会失败得很响亮。这就是不把某一份插件清单的后果写入 `tool-subagent` 所接受的代价。 +one-shot 限制存在于三个配置文件与一处代码注释中,而不在门禁里;CLI preset 行已经选择 `backgroundMode: continuable` 并承担前缀损失。任何组合包或 profile 补丁都能选择任一方式,且不会收到警告。这就是不把某一份插件清单的后果写入 `tool-subagent` 所接受的代价。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 780c4af5ea..57a5f4244b 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: bc84d88d701a5f3018bf00f0ecf8b60750917407 -2026-06-21-subagent-capability-seam.zh.md: 7932181d5667fc8a69ca7aa450fcbf6270ef14d5 +2026-06-21-subagent-capability-seam.md: 70ae99725dffee48292c3481df049563fb54a82c +2026-06-21-subagent-capability-seam.zh.md: 2f63c9022fb116daa2bb6ccd9150d96a823657de diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index bc84d88d70..70ae99725d 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -45,7 +45,7 @@ A provider exposes `start(request) → Promise`. Fulfillment publis ### Two kinds of optional capability, discovered two ways -- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`, `persona`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. +- **Start-time features** (`agentOptions`, `outputSchema`, `depthLimit`, `toolFilter`, `persona`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. - **Continuable creation** is the optional `SubagentProvider.prepareContinuable` method; presence is the capability and TypeScript narrowing is the discovery mechanism, so no separate flag can drift from the implementation. The continuation manager owns later delivery and cold resume directly through `AgentHandle`, while one-shot `SubagentRun` has no steering or resume operation, as refined by [continuable subagents](2026-07-28-continuable-subagent-conversations.md). ### Fork vs. fresh are separate backends, not a flag @@ -60,9 +60,9 @@ Each in-process subagent runs in its **own `Session`** (own id, `parentSession` `dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output; they present the optional safe diagnostic owned by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) separately from partial assistant text. Independent result and disposal rejections remain independently observable. -### Provider selection is config, not model-facing +### Transport provider selection is config, not model-facing -`dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — the schema carries no provider/type parameter. +`dsh-tool-subagent` binds to exactly one subagent transport provider name (`Config.provider`). To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — its schema carries no subagent transport/type parameter. A later opt-in adds child LLM provider/model fields without changing this transport decision; see [model-selected subagent routes](2026-08-18-model-selected-subagent-routes.md). ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 7932181d56..2f63c9022f 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -45,7 +45,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.zh.md)) ### 两类可选能力,两种发现方式 -- **启动时功能**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**响亮拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。 +- **启动时功能**(`agentOptions`、`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**响亮拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。 - **可继续创建**使用可选的 `SubagentProvider.prepareContinuable` 方法;方法是否存在本身即为能力,TypeScript 类型收窄即为发现机制,因此不需要可能与实现失同步的独立 flag。继续执行管理器直接通过 `AgentHandle` 负责后续投递与冷恢复,而一次性 `SubagentRun` 没有 steering 或 resume 操作,具体由[可继续 subagent](2026-07-28-continuable-subagent-conversations.zh.md) 细化。 ### Fork 与 fresh 是独立后端,而非一个 flag @@ -60,9 +60,9 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.zh.md)) `dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;它会把由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的可选安全诊断与部分 assistant 文本分开呈现。结果与 dispose 的拒绝仍可彼此独立地观察。 -### 提供方选择是配置,不面向模型 +### 传输提供方选择是配置,不面向模型 -`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——schema 中没有提供方/type 参数。 +`dsh-tool-subagent` 绑定到恰好一个 subagent 传输提供方名称(`Config.provider`)。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——schema 中没有 subagent 传输/type 参数。后续 opt-in 增加了子 agent LLM 提供方/模型字段,但没有改变这项传输决策;见[模型选择的 subagent 路由](2026-08-18-model-selected-subagent-routes.zh.md)。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml new file mode 100644 index 0000000000..d21dd5be39 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md +2026-08-18-model-selected-subagent-routes.md: 1602e3ac90870edbd0206cd87fdf97ecc34cad41 +2026-08-18-model-selected-subagent-routes.zh.md: 0dad8b9d030e6de65cb3fa1e0e93ad7c28bcc5c1 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md new file mode 100644 index 0000000000..1602e3ac90 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -0,0 +1,61 @@ +# Agent Note: Model-selected subagent routes + +Status: implemented + +English | [中文](2026-08-18-model-selected-subagent-routes.zh.md) + +## Problem + +`dsh-tool-subagent` can configure child `AgentOptions`, and both in-process providers merge those values over the parent Agent's LLM selection. The model-facing tool could not request a different provider, model, or reasoning effort for one suitable subtask. Loading one distinctly named delegation tool per LLM route duplicates schemas and turns a per-call scheduling choice into deployment configuration. + +The model also needs a bounded way to discover live providers and model-owned effort ids. Rendering the adapter directory into every delegation description would make an advisory, mutable catalog part of the prompt prefix. + +## Decision + +`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves an enabled Session decision, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. No route allowlist is required. Registered LLM provider routes are available for child selection; this tool does not add a second authorization policy over the deployment's LLM registry. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. + +Provider and model form one route and must be supplied together. An effort may be supplied alone when configured or parent values provide the effective route. Model arguments override `Config.agentOptions`, and configured fields override the parent Agent's latest logged request selection; creation options supply the fallback before its first request and retain the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort, while changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection. + +An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` before child creation. That lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. The tool checks cancellation again after the asynchronous lookup and before creating a child or background job. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. + +An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. + +Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. + +The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix. + +`SubagentCapabilities.agentOptions` remains the transport truth. The service rejects a request carrying those options before calling a provider that advertises `false`. Both in-process providers advertise `true`; the current ACP, Codex, Claude Code, and DSH SDK transports advertise `false`. Tool configuration that supplies `agentOptions`, statically enables model selection, or makes it settings-controlled also fails when its bound provider lacks the capability. + +## Alternatives considered + +**Keep a deployment-configured route allowlist.** Rejected because it duplicates the live LLM registry, requires configuration before the model can use an already registered route, and creates a second policy surface for clients to edit. Deployments that must restrict LLM access should control which provider routes they register. + +**Render the live adapter catalog in every delegation description.** Rejected because one provider can advertise hundreds of models, inflating every request, and catalog changes would rewrite an early cache-prefix definition. The on-demand directory keeps mutable data out of the fixed schema. + +**Use the advertised catalog as an allowlist.** Rejected because adapter catalogs are advisory and some providers accept arbitrary exact model ids. Exact resolution remains authoritative. + +**Add discovery methods to the subagent service.** Rejected because provider/model/effort metadata already belongs to `ctx.llm`; the new tool is a model-facing consumer of that existing capability. + +**Export discovery as a separately loaded plugin entry.** Rejected because shipped compositions always pair discovery with their primary delegation tool. Explicit ownership on that instance prevents duplicate global names without another Cordis config entry or lifecycle. + +**Configure discovery independently from model-facing selection.** Rejected because discovery exists to supply valid route and effort identifiers to the same model that can select them. One switch prevents a tool schema from advertising selection without its discovery path, or discovery without an applicable delegation route. + +**Enable model-facing route selection on shipped fork tools.** Not shipped because changing provider or model forfeits the inherited prefix's KV Cache reuse and can make prefix recomputation more expensive than the delegated work. The option can be reconsidered when reuse survives the route change or the interface makes that cost explicit and bounded. + +**Use a global reasoning-effort enum.** Rejected because effort identifiers and defaults belong to an exact provider/model route. The LLM adapter validates them without central translation or clamping. + +**Allow remote providers to ignore the fields.** Rejected because the request would claim a route choice that did not happen. The capability flag makes the unsupported path fail before child creation. + +## Consequences + +- An enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. +- The primary delegation-tool instance defaults selection off, exposes a Models-page opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable decision is enabled; its catalog rows do not restrict delegation. +- Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. +- Omission retains configured defaults and compatible inheritance from the parent's latest logged request; a route change without an explicit effort uses the selected model's default. +- Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. +- Out-of-process subagent providers reject configured and model-selected Agent options until they implement and advertise the capability. +- Unit coverage owns the default-off Host preference, new-Session sampling, child inheritance, resumed decisions, opt-in schema and execution enforcement, merge precedence, route-aware effort inheritance, preflight cancellation, live discovery, diagnostics, definition stability, capability rejection, and optional-service behavior. A shipped headless snapshot pins inheritance from a logged parent selection; the shipped examples also own the assembled keyless model-visible schemas. + +## Related decisions + +This note refines only child LLM routing. The fixed subagent transport remains owned by the [subagent capability seam](2026-06-21-subagent-capability-seam.md), while the separate effect of child-scoped prompt and tool additions on fork prefix reuse remains owned by [cache-preserving forked children stay one-shot](../architecture/2026-08-10-fork-children-stay-one-shot.md). diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md new file mode 100644 index 0000000000..0dad8b9d03 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -0,0 +1,61 @@ +# Agent Note: 模型选择的 subagent 路由 + +Status: implemented + +[English](2026-08-18-model-selected-subagent-routes.md) | 中文 + +## 问题 + +`dsh-tool-subagent` 可以配置子级 `AgentOptions`,两个进程内提供方也已把这些值合并到父 Agent 的 LLM 选择之上。但面向模型的工具不能为某个适合的子任务请求不同的提供方、模型或推理强度。为每条 LLM 路由加载一个名称不同的委派工具会重复 schema,并把每次调用的调度选择变成部署配置。 + +模型还需要一种有界方式来发现实时提供方和模型自有的推理强度 ID。把 adapter 目录渲染到每一份委派描述中,会让仅供参考且会变化的目录进入 prompt 前缀。 + +## 决策 + +只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 + +提供方与模型共同组成一条路由,必须一起提供。如果配置值或父级值能够提供生效路由,则可以只提供推理强度。模型参数覆盖 `Config.agentOptions`,配置字段覆盖父 Agent 最新记录的请求选择;首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。路由不变时会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。 + +显式或配置的提供方、模型或强度会在创建子级前通过 `ctx.llm.resolveCallConfig()` 解析。该查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 + +启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 + +随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 + +委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。 + +`SubagentCapabilities.agentOptions` 仍是传输事实。如果请求携带这些选项,而提供方声明为 `false`,服务会在调用提供方前拒绝。两个进程内提供方声明为 `true`;当前 ACP、Codex、Claude Code 与 DSH SDK 传输声明为 `false`。工具配置提供 `agentOptions`、静态启用模型选择或让它受 settings 控制时,如果绑定的提供方缺少该能力,也会失败。 + +## 考虑过的替代方案 + +**保留部署配置的路由允许列表。** 不采用,因为它重复实时 LLM 注册表,要求先配置才能让模型使用已经注册的路由,并为客户端增加第二套策略编辑界面。需要限制 LLM 访问的部署应控制所注册的提供方路由。 + +**在每一份委派描述中渲染实时 adapter 目录。** 不采用,因为一个提供方可能公布数百个模型,从而扩大每次请求,而且目录变化会改写缓存前缀中的早期定义。按需目录让可变数据留在固定 schema 之外。 + +**把公布的目录当作允许列表。** 不采用,因为 adapter 目录只提供建议,有些提供方接受任意精确模型 ID。精确解析仍是权威。 + +**在 subagent 服务中增加发现方法。** 不采用,因为提供方/模型/强度元数据已经属于 `ctx.llm`;新工具只是该现有能力面向模型的 Consumer。 + +**把发现工具作为独立加载的插件入口导出。** 不采用,因为随附组合总是把发现工具与主委派工具配套加载。在该实例上显式指定持有权,可以避免重复的全局工具名,无需增加 Cordis 配置项或独立生命周期。 + +**分别配置发现与面向模型的选择。** 不采用,因为发现功能用于向能够选择这些值的同一个模型提供有效的路由与强度 ID。一个开关可以避免工具 schema 公开选择却没有对应发现路径,或公开发现却没有适用的委派路由。 + +**在随附 fork 工具上启用面向模型的路由选择。** 不随产品提供,因为更改提供方或模型会失去继承前缀的 KV Cache 复用,重新预填充前缀的成本可能高于委派工作本身。只有在路由变化仍能保留复用,或接口能把这项成本显式化并限制住时,才重新考虑该选项。 + +**使用全局推理强度枚举。** 不采用,因为推理强度 ID 和默认值属于精确的提供方/模型路由。LLM adapter 会直接校验,无需中心化翻译或截断。 + +**允许远程提供方忽略这些字段。** 不采用,因为请求会声称发生了实际上没有发生的路由选择。能力标记会让不支持的路径在创建子级前失败。 + +## 结果 + +- 启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 +- 主委派工具实例默认关闭选择,为新 Session 提供 Models 页面 opt-in,并且只在持久决定已启用的 Session 中注册 `list_subagent_models`;其目录条目不会限制委派。 +- 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 +- 省略选择时保留配置默认值,并从父级最新记录的请求中进行兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 +- adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 +- 进程外 subagent 提供方在实现并声明该能力前,会拒绝配置和模型选择的 Agent 选项。 +- 单元测试覆盖默认关闭的 Host 偏好、新 Session 读取、子级继承、恢复决定、选择启用时的 schema 与执行强制、合并优先级、路由相关强度继承、预检取消、实时发现、诊断、定义稳定性、能力拒绝与可选服务行为。随附的 headless 快照固定从父级已记录选择继承的行为;随附示例还覆盖组装后无密钥、模型可见的 schema。 + +## 相关决策 + +本 Note 仅细化子级 LLM 路由。固定的 subagent 传输仍由 [subagent 能力 seam](2026-06-21-subagent-capability-seam.zh.md)负责,而子级作用域提示词与工具增量对 fork 前缀复用产生的独立影响仍由[保留缓存的 fork child 保持 one-shot](../architecture/2026-08-10-fork-children-stay-one-shot.zh.md)负责。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index a4e048b5fd..6af58a3a37 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -11,6 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' @@ -240,6 +241,34 @@ describe('the shipped Web composition', () => { } }) + it('applies the default-off subagent model-selection preference only to new sessions', async () => { + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + const disabled = await ctx.agents.create({ + sessionId: SessionId('preset-model-selection-disabled'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const enabled = await ctx.agents.create({ + sessionId: SessionId('preset-model-selection-enabled'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') + expect(toolParameterNames(ctx, disabled.agent, 'subagent')).not.toEqual(expect.arrayContaining([ + 'model', 'provider', 'reasoning_effort', + ])) + expect(toolNames(ctx, enabled.agent)).toContain('list_subagent_models') + expect(toolParameterNames(ctx, enabled.agent, 'subagent')).toEqual(expect.arrayContaining([ + 'model', 'provider', 'reasoning_effort', + ])) + expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') + } finally { + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await enabled.dispose() + await disabled.dispose() + } + }) + it('composes the exact RL prompt and two tools from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 3c3be0922c..6ac6d76796 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - status: 已保存 minimax-cn。 - list: - listitem: diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md index 1d538bcfe4..2f7d4a3a30 100644 --- a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index df48328fd3..bb129bf2ea 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 54bf1db3c3..dfeb637b5f 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list - text: 提供方 - combobox "提供方": diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index a302932e65..020f70d095 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index 496443b057..73c66388f3 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 406b4015a0..d7e1c70e5a 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 75f050f329e709e5c88bffbe0d3bc2072d4286de -capability-seams.zh.md: 25fa48c67e406b03677debba44eff5d49fd3c626 +capability-seams.md: 0994b186f7daa6afbff0f1484216e55fc79170ff +capability-seams.zh.md: 48aa0a5353bba8d56f63abfcfc4cd2c601569cb6 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 75f050f329..0994b186f7 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -56,6 +56,8 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_file["settings-file"] + pkg_tool_subagent["tool-subagent"] + svc_subagentModelSelection["ctx.subagentModelSelection
Subagent model-selection preference"] pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] @@ -94,7 +96,6 @@ flowchart LR pkg_tool_ask_user["tool-ask-user"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] - pkg_tool_subagent["tool-subagent"] pkg_tool_todo["tool-todo"] pkg_user_questions["user-questions"] svc_userQuestions["ctx.userQuestions
Human question/answer seam"] @@ -310,6 +311,7 @@ flowchart LR pkg_terminal --> svc_terminals pkg_terminal_bash --> svc_terminals pkg_token_meter --> svc_tokenMeter + pkg_tool_subagent --> svc_subagentModelSelection pkg_tools --> svc_tools pkg_typert_registry --> svc_typert pkg_user_questions --> svc_userQuestions @@ -401,6 +403,7 @@ flowchart LR svc_storage --> pkg_storage_domain svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace + svc_subagentModelSelection --> pkg_tool_subagent svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_subagents --> pkg_tool_subagent_control @@ -458,6 +461,7 @@ flowchart LR | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | +| `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 25fa48c67e..48aa0a5353 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -58,6 +58,8 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_file["settings-file"] + pkg_tool_subagent["tool-subagent"] + svc_subagentModelSelection["ctx.subagentModelSelection
Subagent model-selection preference"] pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] @@ -96,7 +98,6 @@ flowchart LR pkg_tool_ask_user["tool-ask-user"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] - pkg_tool_subagent["tool-subagent"] pkg_tool_todo["tool-todo"] pkg_user_questions["user-questions"] svc_userQuestions["ctx.userQuestions
Human question/answer seam"] @@ -312,6 +313,7 @@ flowchart LR pkg_terminal --> svc_terminals pkg_terminal_bash --> svc_terminals pkg_token_meter --> svc_tokenMeter + pkg_tool_subagent --> svc_subagentModelSelection pkg_tools --> svc_tools pkg_typert_registry --> svc_typert pkg_user_questions --> svc_userQuestions @@ -403,6 +405,7 @@ flowchart LR svc_storage --> pkg_storage_domain svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace + svc_subagentModelSelection --> pkg_tool_subagent svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_subagents --> pkg_tool_subagent_control @@ -460,6 +463,7 @@ flowchart LR | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | +| `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index eabedcfe3c..9ba399370d 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: 7f7e5d3c58953ea50c43eed0d903f0d5469e7849 -config-catalog.zh.md: f9ab7a8537e29cb0e74e05e74b4a7890d146c28b +config-catalog.md: 6494070f6204ce6130e22d6f71ddbfd18fdc69dd +config-catalog.zh.md: da19d854ca520425a799cb699238a7dca05a90da diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7f7e5d3c58..6494070f62 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -958,7 +958,7 @@ export interface DeepSeekCatalogModel { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:107`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:117`](../packages/llm/llm-deepseek/src/index.ts) @@ -2841,6 +2841,14 @@ export interface Config { * a distinct name. */ toolName?: string + /** Let the model discover and select the child LLM route (default false). */ + enableModelSelection?: boolean + /** + * Sample the Host `subagent-model-selection` user setting for each new + * top-level session and inherit that decision in its child sessions. Mutually + * exclusive with `enableModelSelection`. + */ + modelSelectionSettings?: boolean /** * Expose `run_in_background` (default true). Disabled instances omit the * parameter and reject forced background calls. @@ -2888,7 +2896,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:29`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f9ab7a8537..da19d854ca 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2843,6 +2843,14 @@ export interface Config { * a distinct name. */ toolName?: string + /** Let the model discover and select the child LLM route (default false). */ + enableModelSelection?: boolean + /** + * Sample the Host `subagent-model-selection` user setting for each new + * top-level session and inherit that decision in its child sessions. Mutually + * exclusive with `enableModelSelection`. + */ + modelSelectionSettings?: boolean /** * Expose `run_in_background` (default true). Disabled instances omit the * parameter and reject forced background calls. @@ -2890,7 +2898,7 @@ export interface Config { 依赖:[`AgentOptions`](subsystems/core.zh.md) -来源:[`packages/subagent/tool-subagent/src/index.ts:29`](../packages/subagent/tool-subagent/src/index.ts) +来源:[`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index c9e637910e..33c536f50a 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: 2be5a84969b9f14823abf90cf289a0a41e48dd11 -event-producer-consumer.zh.md: 5bbae1be5d03c3e443d36093ce60dbf7e4b07971 +event-producer-consumer.md: 4ab1275e9309e150504d6a3bdd80792bb1a49ea1 +event-producer-consumer.zh.md: 8d8a401bfdccc74d5774ce5ce7ceac2c548f6c72 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2be5a84969..4ab1275e93 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,18 +9,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `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), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team` | -| `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), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team` | -| `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), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`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-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:199`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:207`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:188`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:233`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:246`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:219`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:180`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -52,13 +52,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../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:144`](../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:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5bbae1be5d..8d8a401bfd 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,13 +11,13 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `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), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team` | -| `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), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-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), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`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-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | @@ -54,13 +54,13 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../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:144`](../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:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a7edea20b2..a467f4aea6 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: a2497ed002c6eec55ec35e1d7e9953feb2ed0ee5 -module-graph.zh.md: 230d67f4c07f9715d4b5157c1539edd09f9221c3 +module-graph.md: 9d192404f4949bcf6e5c4222f0b898cc52362bd8 +module-graph.zh.md: bbdfd5642238644939fd08c02b9786310bad63e2 diff --git a/docs/module-graph.md b/docs/module-graph.md index a2497ed002..9d192404f4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -570,14 +570,6 @@ flowchart TD pkg_jobs --> pkg_brand pkg_jobs --> pkg_invariants pkg_jobs --> pkg_session - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -655,11 +647,6 @@ flowchart TD pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout - pkg_plugin_package_inventory_deepseek --> pkg_agent - pkg_plugin_package_inventory_deepseek --> pkg_agent_presets - pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions - pkg_plugin_package_inventory_deepseek --> pkg_invariants - pkg_plugin_package_inventory_deepseek --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -710,8 +697,6 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_session_telemetry - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -812,21 +797,6 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_subagent --> pkg_agent - pkg_subagent --> pkg_agent_presets - pkg_subagent --> pkg_brand - pkg_subagent --> pkg_invariants - pkg_subagent --> pkg_jobs - pkg_subagent --> pkg_llm - pkg_subagent --> pkg_sandbox - pkg_subagent --> pkg_sandbox_policy - pkg_subagent --> pkg_scope - pkg_subagent --> pkg_session - pkg_subagent --> pkg_session_persistence - pkg_subagent --> pkg_session_projection - pkg_subagent --> pkg_session_projection_cache - pkg_subagent --> pkg_tools - pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -874,10 +844,6 @@ flowchart TD pkg_file_reference_local --> pkg_invariants pkg_file_reference_local --> pkg_system_prompt pkg_file_reference_local --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -917,6 +883,15 @@ flowchart TD pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_home_paths + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt + pkg_agent_presets --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -990,6 +965,89 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_workflow --> pkg_agent + pkg_tool_workflow --> pkg_invariants + pkg_tool_workflow --> pkg_llm + pkg_tool_workflow --> pkg_session + pkg_tool_workflow --> pkg_system_prompt + pkg_tool_workflow --> pkg_tools + pkg_tool_workflow --> pkg_workflow + pkg_plugin_package_inventory_deepseek --> pkg_agent + pkg_plugin_package_inventory_deepseek --> pkg_agent_presets + pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions + pkg_plugin_package_inventory_deepseek --> pkg_invariants + pkg_plugin_package_inventory_deepseek --> pkg_session + pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_brand + pkg_subagent --> pkg_invariants + pkg_subagent --> pkg_jobs + pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy + pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection + pkg_subagent --> pkg_session_projection_cache + pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval + pkg_session_query --> pkg_brand + pkg_session_query --> pkg_invariants + pkg_session_query --> pkg_llm + pkg_session_query --> pkg_session + pkg_session_query --> pkg_session_persistence + pkg_session_query --> pkg_session_title + pkg_session_query --> pkg_tool_todo + pkg_acp --> pkg_agent + pkg_acp --> pkg_attachment + pkg_acp --> pkg_invariants + pkg_acp --> pkg_llm + pkg_acp --> pkg_mcp_client + pkg_acp --> pkg_session + pkg_acp --> pkg_session_persistence + pkg_acp --> pkg_token_meter + pkg_acp --> pkg_user_approval + pkg_web_app --> pkg_invariants + pkg_web_app --> pkg_shell_env + pkg_web_app --> pkg_system_prompt + pkg_compaction_tool_result_pruner --> pkg_compaction + pkg_compaction_tool_result_pruner --> pkg_invariants + pkg_compaction_tool_result_pruner --> pkg_llm + pkg_compaction_tool_result_pruner --> pkg_session + pkg_compaction_tool_result_pruner --> pkg_token_meter + pkg_tool_cordis --> pkg_agent + pkg_tool_cordis --> pkg_cordis_host_runner + pkg_tool_cordis --> pkg_invariants + pkg_tool_cordis --> pkg_llm + pkg_tool_cordis --> pkg_scope + pkg_tool_cordis --> pkg_session + pkg_tool_cordis --> pkg_system_prompt + pkg_tool_cordis --> pkg_tools + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_jobs + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_shell + pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_jobs + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy + pkg_tool_pwsh --> pkg_shell + pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_presets @@ -1000,13 +1058,6 @@ flowchart TD pkg_webhook --> pkg_session pkg_webhook --> pkg_session_title pkg_webhook --> pkg_workspace - pkg_tool_workflow --> pkg_agent - pkg_tool_workflow --> pkg_invariants - pkg_tool_workflow --> pkg_llm - pkg_tool_workflow --> pkg_session - pkg_tool_workflow --> pkg_system_prompt - pkg_tool_workflow --> pkg_tools - pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1037,6 +1088,9 @@ flowchart TD pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_jobs pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_scope + pkg_tool_subagent --> pkg_session + pkg_tool_subagent --> pkg_settings pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_system_prompt pkg_tool_subagent --> pkg_tools @@ -1058,107 +1112,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools - pkg_session_query --> pkg_brand - pkg_session_query --> pkg_invariants - pkg_session_query --> pkg_llm - pkg_session_query --> pkg_session - pkg_session_query --> pkg_session_persistence - pkg_session_query --> pkg_session_title - pkg_session_query --> pkg_tool_todo - pkg_acp --> pkg_agent - pkg_acp --> pkg_attachment - pkg_acp --> pkg_invariants - pkg_acp --> pkg_llm - pkg_acp --> pkg_mcp_client - pkg_acp --> pkg_session - pkg_acp --> pkg_session_persistence - pkg_acp --> pkg_token_meter - pkg_acp --> pkg_user_approval - pkg_web_app --> pkg_invariants - pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_tool_todo - pkg_compaction_tool_result_pruner --> pkg_compaction - pkg_compaction_tool_result_pruner --> pkg_invariants - pkg_compaction_tool_result_pruner --> pkg_llm - pkg_compaction_tool_result_pruner --> pkg_session - pkg_compaction_tool_result_pruner --> pkg_token_meter - pkg_experimental_agent_team --> pkg_agent - pkg_experimental_agent_team --> pkg_brand - pkg_experimental_agent_team --> pkg_invariants - pkg_experimental_agent_team --> pkg_llm - pkg_experimental_agent_team --> pkg_session - pkg_experimental_agent_team --> pkg_session_persistence - pkg_experimental_agent_team --> pkg_subagent - pkg_tool_cordis --> pkg_agent - pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_invariants - pkg_tool_cordis --> pkg_llm - pkg_tool_cordis --> pkg_scope - pkg_tool_cordis --> pkg_session - pkg_tool_cordis --> pkg_system_prompt - pkg_tool_cordis --> pkg_tools - pkg_sdk_protocol --> pkg_invariants - pkg_sdk_protocol --> pkg_llm - pkg_sdk_protocol --> pkg_session - pkg_sdk_protocol --> pkg_subagent - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_jobs - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_shell - pkg_tool_bash --> pkg_shell_env - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_agent - pkg_tool_pwsh --> pkg_invariants - pkg_tool_pwsh --> pkg_jobs - pkg_tool_pwsh --> pkg_llm - pkg_tool_pwsh --> pkg_sandbox - pkg_tool_pwsh --> pkg_sandbox_policy - pkg_tool_pwsh --> pkg_shell - pkg_tool_pwsh --> pkg_shell_env - pkg_tool_pwsh --> pkg_system_prompt - pkg_tool_pwsh --> pkg_tools - pkg_tool_pwsh --> pkg_user_approval - pkg_webhook_github --> pkg_credentials - pkg_webhook_github --> pkg_host_webserver - pkg_webhook_github --> pkg_invariants - pkg_webhook_github --> pkg_session - pkg_webhook_github --> pkg_webhook - pkg_tool_ralph --> pkg_agent - pkg_tool_ralph --> pkg_invariants - pkg_tool_ralph --> pkg_llm - pkg_tool_ralph --> pkg_subagent - pkg_tool_ralph --> pkg_system_prompt - pkg_tool_ralph --> pkg_tools - pkg_tool_ralph --> pkg_workflow - pkg_workflow_worker_thread --> pkg_agent - pkg_workflow_worker_thread --> pkg_brand - pkg_workflow_worker_thread --> pkg_invariants - pkg_workflow_worker_thread --> pkg_llm - pkg_workflow_worker_thread --> pkg_session - pkg_workflow_worker_thread --> pkg_subagent - pkg_workflow_worker_thread --> pkg_tools - pkg_workflow_worker_thread --> pkg_workflow - pkg_subagent_fork_in_process --> pkg_agent - pkg_subagent_fork_in_process --> pkg_invariants - pkg_subagent_fork_in_process --> pkg_session - pkg_subagent_fork_in_process --> pkg_subagent - pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver - pkg_subagent_spawn_in_process --> pkg_invariants - pkg_subagent_spawn_in_process --> pkg_subagent - pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1170,11 +1123,14 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1213,6 +1169,54 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_agent_team --> pkg_agent + pkg_experimental_agent_team --> pkg_brand + pkg_experimental_agent_team --> pkg_invariants + pkg_experimental_agent_team --> pkg_llm + pkg_experimental_agent_team --> pkg_session + pkg_experimental_agent_team --> pkg_session_persistence + pkg_experimental_agent_team --> pkg_subagent + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants + pkg_sdk_protocol --> pkg_invariants + pkg_sdk_protocol --> pkg_llm + pkg_sdk_protocol --> pkg_session + pkg_sdk_protocol --> pkg_subagent + pkg_webhook_github --> pkg_credentials + pkg_webhook_github --> pkg_host_webserver + pkg_webhook_github --> pkg_invariants + pkg_webhook_github --> pkg_session + pkg_webhook_github --> pkg_webhook + pkg_tool_ralph --> pkg_agent + pkg_tool_ralph --> pkg_invariants + pkg_tool_ralph --> pkg_llm + pkg_tool_ralph --> pkg_subagent + pkg_tool_ralph --> pkg_system_prompt + pkg_tool_ralph --> pkg_tools + pkg_tool_ralph --> pkg_workflow + pkg_workflow_worker_thread --> pkg_agent + pkg_workflow_worker_thread --> pkg_brand + pkg_workflow_worker_thread --> pkg_invariants + pkg_workflow_worker_thread --> pkg_llm + pkg_workflow_worker_thread --> pkg_session + pkg_workflow_worker_thread --> pkg_subagent + pkg_workflow_worker_thread --> pkg_tools + pkg_workflow_worker_thread --> pkg_workflow + pkg_subagent_fork_in_process --> pkg_agent + pkg_subagent_fork_in_process --> pkg_invariants + pkg_subagent_fork_in_process --> pkg_session + pkg_subagent_fork_in_process --> pkg_subagent + pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver + pkg_subagent_spawn_in_process --> pkg_invariants + pkg_subagent_spawn_in_process --> pkg_subagent + pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1731,7 +1735,6 @@ flowchart TD | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1747,7 +1750,6 @@ flowchart TD | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1760,7 +1762,6 @@ flowchart TD | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/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), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1777,7 +1778,6 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1786,7 +1786,6 @@ flowchart TD | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1794,6 +1793,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1807,37 +1807,41 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/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) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | -| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/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) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 230d67f4c0..bbdfd56422 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -572,14 +572,6 @@ flowchart TD pkg_jobs --> pkg_brand pkg_jobs --> pkg_invariants pkg_jobs --> pkg_session - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -657,11 +649,6 @@ flowchart TD pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout - pkg_plugin_package_inventory_deepseek --> pkg_agent - pkg_plugin_package_inventory_deepseek --> pkg_agent_presets - pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions - pkg_plugin_package_inventory_deepseek --> pkg_invariants - pkg_plugin_package_inventory_deepseek --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -712,8 +699,6 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_session_telemetry - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -814,21 +799,6 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_subagent --> pkg_agent - pkg_subagent --> pkg_agent_presets - pkg_subagent --> pkg_brand - pkg_subagent --> pkg_invariants - pkg_subagent --> pkg_jobs - pkg_subagent --> pkg_llm - pkg_subagent --> pkg_sandbox - pkg_subagent --> pkg_sandbox_policy - pkg_subagent --> pkg_scope - pkg_subagent --> pkg_session - pkg_subagent --> pkg_session_persistence - pkg_subagent --> pkg_session_projection - pkg_subagent --> pkg_session_projection_cache - pkg_subagent --> pkg_tools - pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -876,10 +846,6 @@ flowchart TD pkg_file_reference_local --> pkg_invariants pkg_file_reference_local --> pkg_system_prompt pkg_file_reference_local --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -919,6 +885,15 @@ flowchart TD pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_home_paths + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt + pkg_agent_presets --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -992,6 +967,89 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_workflow --> pkg_agent + pkg_tool_workflow --> pkg_invariants + pkg_tool_workflow --> pkg_llm + pkg_tool_workflow --> pkg_session + pkg_tool_workflow --> pkg_system_prompt + pkg_tool_workflow --> pkg_tools + pkg_tool_workflow --> pkg_workflow + pkg_plugin_package_inventory_deepseek --> pkg_agent + pkg_plugin_package_inventory_deepseek --> pkg_agent_presets + pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions + pkg_plugin_package_inventory_deepseek --> pkg_invariants + pkg_plugin_package_inventory_deepseek --> pkg_session + pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_brand + pkg_subagent --> pkg_invariants + pkg_subagent --> pkg_jobs + pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy + pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection + pkg_subagent --> pkg_session_projection_cache + pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval + pkg_session_query --> pkg_brand + pkg_session_query --> pkg_invariants + pkg_session_query --> pkg_llm + pkg_session_query --> pkg_session + pkg_session_query --> pkg_session_persistence + pkg_session_query --> pkg_session_title + pkg_session_query --> pkg_tool_todo + pkg_acp --> pkg_agent + pkg_acp --> pkg_attachment + pkg_acp --> pkg_invariants + pkg_acp --> pkg_llm + pkg_acp --> pkg_mcp_client + pkg_acp --> pkg_session + pkg_acp --> pkg_session_persistence + pkg_acp --> pkg_token_meter + pkg_acp --> pkg_user_approval + pkg_web_app --> pkg_invariants + pkg_web_app --> pkg_shell_env + pkg_web_app --> pkg_system_prompt + pkg_compaction_tool_result_pruner --> pkg_compaction + pkg_compaction_tool_result_pruner --> pkg_invariants + pkg_compaction_tool_result_pruner --> pkg_llm + pkg_compaction_tool_result_pruner --> pkg_session + pkg_compaction_tool_result_pruner --> pkg_token_meter + pkg_tool_cordis --> pkg_agent + pkg_tool_cordis --> pkg_cordis_host_runner + pkg_tool_cordis --> pkg_invariants + pkg_tool_cordis --> pkg_llm + pkg_tool_cordis --> pkg_scope + pkg_tool_cordis --> pkg_session + pkg_tool_cordis --> pkg_system_prompt + pkg_tool_cordis --> pkg_tools + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_jobs + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_shell + pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_jobs + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy + pkg_tool_pwsh --> pkg_shell + pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_presets @@ -1002,13 +1060,6 @@ flowchart TD pkg_webhook --> pkg_session pkg_webhook --> pkg_session_title pkg_webhook --> pkg_workspace - pkg_tool_workflow --> pkg_agent - pkg_tool_workflow --> pkg_invariants - pkg_tool_workflow --> pkg_llm - pkg_tool_workflow --> pkg_session - pkg_tool_workflow --> pkg_system_prompt - pkg_tool_workflow --> pkg_tools - pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1039,6 +1090,9 @@ flowchart TD pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_jobs pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_scope + pkg_tool_subagent --> pkg_session + pkg_tool_subagent --> pkg_settings pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_system_prompt pkg_tool_subagent --> pkg_tools @@ -1060,107 +1114,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools - pkg_session_query --> pkg_brand - pkg_session_query --> pkg_invariants - pkg_session_query --> pkg_llm - pkg_session_query --> pkg_session - pkg_session_query --> pkg_session_persistence - pkg_session_query --> pkg_session_title - pkg_session_query --> pkg_tool_todo - pkg_acp --> pkg_agent - pkg_acp --> pkg_attachment - pkg_acp --> pkg_invariants - pkg_acp --> pkg_llm - pkg_acp --> pkg_mcp_client - pkg_acp --> pkg_session - pkg_acp --> pkg_session_persistence - pkg_acp --> pkg_token_meter - pkg_acp --> pkg_user_approval - pkg_web_app --> pkg_invariants - pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_tool_todo - pkg_compaction_tool_result_pruner --> pkg_compaction - pkg_compaction_tool_result_pruner --> pkg_invariants - pkg_compaction_tool_result_pruner --> pkg_llm - pkg_compaction_tool_result_pruner --> pkg_session - pkg_compaction_tool_result_pruner --> pkg_token_meter - pkg_experimental_agent_team --> pkg_agent - pkg_experimental_agent_team --> pkg_brand - pkg_experimental_agent_team --> pkg_invariants - pkg_experimental_agent_team --> pkg_llm - pkg_experimental_agent_team --> pkg_session - pkg_experimental_agent_team --> pkg_session_persistence - pkg_experimental_agent_team --> pkg_subagent - pkg_tool_cordis --> pkg_agent - pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_invariants - pkg_tool_cordis --> pkg_llm - pkg_tool_cordis --> pkg_scope - pkg_tool_cordis --> pkg_session - pkg_tool_cordis --> pkg_system_prompt - pkg_tool_cordis --> pkg_tools - pkg_sdk_protocol --> pkg_invariants - pkg_sdk_protocol --> pkg_llm - pkg_sdk_protocol --> pkg_session - pkg_sdk_protocol --> pkg_subagent - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_jobs - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_shell - pkg_tool_bash --> pkg_shell_env - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_agent - pkg_tool_pwsh --> pkg_invariants - pkg_tool_pwsh --> pkg_jobs - pkg_tool_pwsh --> pkg_llm - pkg_tool_pwsh --> pkg_sandbox - pkg_tool_pwsh --> pkg_sandbox_policy - pkg_tool_pwsh --> pkg_shell - pkg_tool_pwsh --> pkg_shell_env - pkg_tool_pwsh --> pkg_system_prompt - pkg_tool_pwsh --> pkg_tools - pkg_tool_pwsh --> pkg_user_approval - pkg_webhook_github --> pkg_credentials - pkg_webhook_github --> pkg_host_webserver - pkg_webhook_github --> pkg_invariants - pkg_webhook_github --> pkg_session - pkg_webhook_github --> pkg_webhook - pkg_tool_ralph --> pkg_agent - pkg_tool_ralph --> pkg_invariants - pkg_tool_ralph --> pkg_llm - pkg_tool_ralph --> pkg_subagent - pkg_tool_ralph --> pkg_system_prompt - pkg_tool_ralph --> pkg_tools - pkg_tool_ralph --> pkg_workflow - pkg_workflow_worker_thread --> pkg_agent - pkg_workflow_worker_thread --> pkg_brand - pkg_workflow_worker_thread --> pkg_invariants - pkg_workflow_worker_thread --> pkg_llm - pkg_workflow_worker_thread --> pkg_session - pkg_workflow_worker_thread --> pkg_subagent - pkg_workflow_worker_thread --> pkg_tools - pkg_workflow_worker_thread --> pkg_workflow - pkg_subagent_fork_in_process --> pkg_agent - pkg_subagent_fork_in_process --> pkg_invariants - pkg_subagent_fork_in_process --> pkg_session - pkg_subagent_fork_in_process --> pkg_subagent - pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver - pkg_subagent_spawn_in_process --> pkg_invariants - pkg_subagent_spawn_in_process --> pkg_subagent - pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1172,11 +1125,14 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1215,6 +1171,54 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_agent_team --> pkg_agent + pkg_experimental_agent_team --> pkg_brand + pkg_experimental_agent_team --> pkg_invariants + pkg_experimental_agent_team --> pkg_llm + pkg_experimental_agent_team --> pkg_session + pkg_experimental_agent_team --> pkg_session_persistence + pkg_experimental_agent_team --> pkg_subagent + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants + pkg_sdk_protocol --> pkg_invariants + pkg_sdk_protocol --> pkg_llm + pkg_sdk_protocol --> pkg_session + pkg_sdk_protocol --> pkg_subagent + pkg_webhook_github --> pkg_credentials + pkg_webhook_github --> pkg_host_webserver + pkg_webhook_github --> pkg_invariants + pkg_webhook_github --> pkg_session + pkg_webhook_github --> pkg_webhook + pkg_tool_ralph --> pkg_agent + pkg_tool_ralph --> pkg_invariants + pkg_tool_ralph --> pkg_llm + pkg_tool_ralph --> pkg_subagent + pkg_tool_ralph --> pkg_system_prompt + pkg_tool_ralph --> pkg_tools + pkg_tool_ralph --> pkg_workflow + pkg_workflow_worker_thread --> pkg_agent + pkg_workflow_worker_thread --> pkg_brand + pkg_workflow_worker_thread --> pkg_invariants + pkg_workflow_worker_thread --> pkg_llm + pkg_workflow_worker_thread --> pkg_session + pkg_workflow_worker_thread --> pkg_subagent + pkg_workflow_worker_thread --> pkg_tools + pkg_workflow_worker_thread --> pkg_workflow + pkg_subagent_fork_in_process --> pkg_agent + pkg_subagent_fork_in_process --> pkg_invariants + pkg_subagent_fork_in_process --> pkg_session + pkg_subagent_fork_in_process --> pkg_subagent + pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver + pkg_subagent_spawn_in_process --> pkg_invariants + pkg_subagent_spawn_in_process --> pkg_subagent + pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1642,7 +1646,7 @@ flowchart TD pkg_client_ui_cordis --> pkg_invariants ``` -| Package | Group | Depends on | +| 包 | 分组 | 依赖 | | --- | --- | --- | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1733,7 +1737,6 @@ flowchart TD | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1749,7 +1752,6 @@ flowchart TD | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1762,7 +1764,6 @@ flowchart TD | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/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), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1779,7 +1780,6 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1788,7 +1788,6 @@ flowchart TD | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1796,6 +1795,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1809,37 +1809,41 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/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) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | -| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/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) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 06c60504dc..aad7db87f6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 5155968af0886a389d0b01d9332af927cff95a55 -persistence-catalog.zh.md: abd4ae767a5cfca45be76b54d392815244070869 +persistence-catalog.md: dd2124520e43e590fc3506b23c533b3e482132cd +persistence-catalog.zh.md: 48c0867f37fc87ce5d29ce04b0b6970fca83648c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 5155968af0..dd2124520e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -726,7 +726,23 @@ Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/ 'subagent/descriptor': SubagentDescriptorData ``` -Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) +Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) + + + +#### `subagent/model-selection-enabled` — log-only + +```ts persistence-catalog +/** + * Records that this session's delegation tool exposes child provider, + * model, and reasoning-effort selection. Appended before the first model + * request; absence means the fixed-route definition. Log-only: it carries + * no `surfaceOp` and never enters model history. + */ +'subagent/model-selection-enabled': Record +``` + +Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index abd4ae767a..48c0867f37 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -728,7 +728,23 @@ export type SessionEvent = { 'subagent/descriptor': SubagentDescriptorData ``` -来源:[`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) +来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) + + + +#### `subagent/model-selection-enabled` — log-only + +```ts persistence-catalog +/** + * Records that this session's delegation tool exposes child provider, + * model, and reasoning-effort selection. Appended before the first model + * request; absence means the fixed-route definition. Log-only: it carries + * no `surfaceOp` and never enters model history. + */ +'subagent/model-selection-enabled': Record +``` + +来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 9174e27772..fb5dd11fbb 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: 3417560539f41009a290f4c31b255f338624d56d -core.zh.md: 75f3c0ca16e1235cb2155f6e54e86e414e61b498 +core.md: c53bb94fa5918c3a91ee9aedbb2416d0b101b240 +core.zh.md: 93ee45fb88cc100eb77673f2b70e86483c7ed29f diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 3417560539..c53bb94fa5 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -161,12 +161,14 @@ interface AgentOptions { provider?: string /** Model id interpreted by the selected provider adapter. */ model?: string + /** Adapter-owned reasoning effort for the selected provider/model route. */ + reasoningEffort?: ReasoningEffortId /** Maximum output tokens for each conversation-model request. */ maxTokens?: number } ``` -Dispatch requires `provider` and `model` after `agent/request`. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona` prompt section may shadow the global default persona. +Dispatch requires `provider` and `model` after `agent/request`. An explicit `reasoningEffort` seeds the first request on that route; exact-model resolution validates it, while omission allows the adapter default to materialize. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona` prompt section may shadow the global default persona. The inbox is the delivery vocabulary — two ordered pending-message lists the agent owns as a durable projection: @@ -524,7 +526,9 @@ serviceFor(agent: { ctx: Context }, name: K): * state to restore. The re-link runs through the binding this roster kept * from the agent's mount — dsh-scope's only re-link authority. An agent * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. + * agent's first bind, exactly a mount. A committed re-link emits + * `tools/change` because changing the parent scope changes the Agent's + * resolved tool set without adding or removing registry entries. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 75f3c0ca16..93ee45fb88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -165,12 +165,14 @@ interface AgentOptions { provider?: string /** Model id interpreted by the selected provider adapter. */ model?: string + /** Adapter-owned reasoning effort for the selected provider/model route. */ + reasoningEffort?: ReasoningEffortId /** Maximum output tokens for each conversation-model request. */ maxTokens?: number } ``` -在 `agent/request` 之后,分发要求 `provider` 与 `model` 都存在。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona` 提示词段落可以遮蔽全局默认 persona。 +在 `agent/request` 之后,分发要求 `provider` 与 `model` 都存在。显式 `reasoningEffort` 会为该路由的首次请求提供初始值;确切模型解析会校验该值,省略时则允许填入适配器默认值。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona` 提示词段落可以遮蔽全局默认 persona。 inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待处理消息列表: @@ -534,7 +536,9 @@ serviceFor(agent: { ctx: Context }, name: K): * state to restore. The re-link runs through the binding this roster kept * from the agent's mount — dsh-scope's only re-link authority. An agent * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. + * agent's first bind, exactly a mount. A committed re-link emits + * `tools/change` because changing the parent scope changes the Agent's + * resolved tool set without adding or removing registry entries. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index ac6150111a..cd3f9d99c9 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: 7a0ba28afbbec88465415b3411946a0afa0da918 -subagent.zh.md: f397c19a0dc79c0c5a94e617e2013b68faa4d2e9 +subagent.md: 63edef0a4b8d5368ea9d6d82f0ea3ef99ea0bbad +subagent.zh.md: 21b0dfd21dbee5e1d37558d6c02fe7949126d9a9 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 7a0ba28afb..63edef0a4b 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -25,6 +25,7 @@ A provider advertises its **start-time** features on a static descriptor the ser * to `maxDepth`; the other names match. */ interface SubagentCapabilities { + readonly agentOptions: boolean readonly outputSchema: boolean readonly depthLimit: boolean readonly toolFilter: boolean @@ -34,7 +35,7 @@ interface SubagentCapabilities { ## The one-shot start request -The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. +The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. Current out-of-process providers reject `agentOptions` before starting their transport. ```ts type-equiv /** @@ -63,6 +64,12 @@ interface SubagentStartRequest { * remaining turn work when it fires afterward. */ readonly signal: AbortSignal + /** + * Optional host-Agent provider, model, reasoning-effort, and output-token + * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process + * providers merge them over the parent Agent's options when they create the + * child. + */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects @@ -280,7 +287,7 @@ interface ContinuableCreateSpec { } ``` -The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. It never snapshots the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (one run or Activation's result contract, not durable identity). +The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model`/`reasoningEffort` and optional `persona`/`toolFilter` for cold resume. It never snapshots the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (one run or Activation's result contract, not durable identity). A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the list-serving identity projection folds `subagent/descriptor` last-wins so the child's own descriptor overrides a fork-seeded ancestor's. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. @@ -482,6 +489,22 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` + +Singleton settings owner read by delegation tools when an Agent is published. + +```ts cordis-catalog +/** + * Read the preference for the next eligible Agent publication. + * @returns whether that Agent should receive model-selectable delegation. + */ +currentEnabled(): boolean +``` + +Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) + ### `ctx.subagents` — `SubagentRuntime` diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index f397c19a0d..21b0dfd21d 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -25,6 +25,7 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub * to `maxDepth`; the other names match. */ interface SubagentCapabilities { + readonly agentOptions: boolean readonly outputSchema: boolean readonly depthLimit: boolean readonly toolFilter: boolean @@ -34,7 +35,7 @@ interface SubagentCapabilities { ## 单次启动请求 -工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。 +工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。当前进程外提供方会在启动其传输前拒绝 `agentOptions`。 ```ts type-equiv /** @@ -63,6 +64,12 @@ interface SubagentStartRequest { * remaining turn work when it fires afterward. */ readonly signal: AbortSignal + /** + * Optional host-Agent provider, model, reasoning-effort, and output-token + * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process + * providers merge them over the parent Agent's options when they create the + * child. + */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects @@ -280,7 +287,7 @@ interface ContinuableCreateSpec { } ``` -描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果约定,而非持久化身份)。 +描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model`/`reasoningEffort` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果约定,而非持久化身份)。 本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始提示词获准之前追加描述符;`header.seedLength` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 @@ -486,6 +493,22 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将 Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` + +Singleton settings owner read by delegation tools when an Agent is published. + +```ts cordis-catalog +/** + * Read the preference for the next eligible Agent publication. + * @returns whether that Agent should receive model-selectable delegation. + */ +currentEnabled(): boolean +``` + +Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) + ### `ctx.subagents` — `SubagentRuntime` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 10447d6107..38ad384da8 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: 1fa650f1e4e025274d069f27a6522abff46af2e2 -tool-catalog.zh.md: c3209e7007e9cf05770ccee0698f9e98a32e8363 +tool-catalog.md: 0cd8560a6851f0195e2272d1bd3b0bec2c171ac4 +tool-catalog.zh.md: cb225bc11afa6a6b022f2c7c104d4e1286f89260 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1fa650f1e4..0cd8560a68 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt` | `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, `run_in_background` parameter, and system-prompt policy follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable` and defaults omitted calls to background with automatic settlement delivery, while `subagent_fork` stays `one-shot` and defaults them to foreground — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-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-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | @@ -1501,9 +1501,31 @@ The five read-only tools hide provider cursors and authorize every result from t ## `@deepseek-ai/dsh-tool-subagent` +### `list_subagent_models` + +Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. + +```json +{ + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } +} +``` + +Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subagent/tool-subagent/src/list-models.ts) + ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. ```json { @@ -1517,6 +1539,18 @@ Delegate a self-contained task to a subagent (a separate agent that works in its "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1531,7 +1565,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 compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance's description, `run_in_background` parameter, and system-prompt policy follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable` and defaults omitted calls to background with automatic settlement delivery, while `subagent_fork` stays `one-shot` and defaults them to foreground — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index c3209e7007..cb225bc11a 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -37,7 +37,7 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 对应默认值。随产品发布的组合会为每个 subagent 后端加载一次该包,因此模型还会看到绑定到 fork 后端的 `subagent_fork`。每个实例的描述、`run_in_background` 参数与 system prompt 策略取决于它自己的 `backgroundMode` 和 `enableRunInBackground`,因此两个随附 schema 并不相同:`subagent` 为 `continuable`,省略参数时默认后台运行,并由 runtime 自动投递结束结果;`subagent_fork` 保持 `one-shot`,省略参数时默认前台运行。详见 `packages/bundle/base/cordis.patch.yml` 和 `examples/acp-agent/cordis.yml`。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-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-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | @@ -1507,9 +1507,31 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ## `@deepseek-ai/dsh-tool-subagent` +### `list_subagent_models` + +发现 subagent 可用的 LLM 路由,不更改当前 Agent。无参数调用会列出已注册提供方;提供 `provider` 时会列出其公布的模型;同时提供 `provider` 和 `model` 时会检查该精确模型及其推理强度。目录条目只提供建议:adapter 可能接受未列出的模型 id。把返回的 id 用于委派工具的 `provider`、`model` 与 `reasoning_effort` 字段。 + +```json +{ + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } +} +``` + +来源:[`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subagent/tool-subagent/src/list-models.ts) + ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。子级 LLM 选择是可选的。省略 `provider`、`model` 与 `reasoning_effort` 会使用配置的子级默认值,并从父 Agent 继承兼容的缺失值。先用 `list_subagent_models` 检查公布的路由和强度,再一起提供 `provider` 与 `model`。改变生效路由但不指定强度时,会使用所选模型的默认强度。 ```json { @@ -1523,6 +1545,18 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1537,7 +1571,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 对应默认值。随产品发布的组合会为每个 subagent 后端加载一次该包,因此模型还会看到绑定到 fork 后端的 `subagent_fork`。每个实例的描述、`run_in_background` 参数与 system prompt 策略取决于它自己的 `backgroundMode` 和 `enableRunInBackground`,因此两个随附 schema 并不相同:`subagent` 为 `continuable`,省略参数时默认后台运行,并由 runtime 自动投递结束结果;`subagent_fork` 保持 `one-shot`,省略参数时默认前台运行。详见 `packages/bundle/base/cordis.patch.yml` 和 `examples/acp-agent/cordis.yml`。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index ddfb914132..8f1df1955d 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -54,9 +54,17 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable maxDepth: 1 +# Fork omits model selection so provider/model stay equal to the parent and the +# inherited history remains eligible for KV Cache reuse. It stays one-shot because +# a continuable child's `report` tool and prompt section precede that history and +# invalidate the same prefix. `run_in_background` is off as an explicit foreground-only +# choice even though the shipped ACP profile mounts the generic Job runtime. +# See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +# and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml index 89ef8c1035..245253690f 100644 --- a/examples/acp-agent/depth-two.cordis.snapshot.yml +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -20,6 +20,7 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable maxDepth: 2 # Re-pin the recorded flash model for this scenario's corpus. diff --git a/examples/acp-agent/depth-two.cordis.yml b/examples/acp-agent/depth-two.cordis.yml index b2d4dbe039..d25b48fb60 100644 --- a/examples/acp-agent/depth-two.cordis.yml +++ b/examples/acp-agent/depth-two.cordis.yml @@ -5,5 +5,6 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable maxDepth: 2 diff --git a/examples/acp-agent/subagent-configured-effort.cordis.snapshot.yml b/examples/acp-agent/subagent-configured-effort.cordis.snapshot.yml new file mode 100644 index 0000000000..c89a77d49d --- /dev/null +++ b/examples/acp-agent/subagent-configured-effort.cordis.snapshot.yml @@ -0,0 +1,65 @@ +# Keyless counterpart to subagent-configured-effort.cordis.yml: disable the +# live adapter, insert replay, and apply the configured-effort rejection patch. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + +- 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 + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + enableModelSelection: true + backgroundMode: continuable + maxDepth: 1 + agentOptions: + provider: deepseek-official + model: deepseek-v4-flash + reasoningEffort: unsupported + +- id: acp + name: '@deepseek-ai/dsh-acp' + config: + provider: deepseek-official + model: deepseek-v4-flash + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + compression: none + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + 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. + +- 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 diff --git a/examples/acp-agent/subagent-configured-effort.cordis.yml b/examples/acp-agent/subagent-configured-effort.cordis.yml new file mode 100644 index 0000000000..9042fd3d91 --- /dev/null +++ b/examples/acp-agent/subagent-configured-effort.cordis.yml @@ -0,0 +1,15 @@ +# Configured-effort rejection snapshot overlay: keep one invalid configured +# effort so the tool rejects before starting a child instead of deferring the +# failure to the child agent loop. +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + enableModelSelection: true + backgroundMode: continuable + maxDepth: 1 + agentOptions: + provider: deepseek-official + model: deepseek-v4-flash + reasoningEffort: unsupported diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index f6bd6124e7..73286b97a6 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -70,6 +70,9 @@ const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( const SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG = fileURLToPath( new URL('../subagent-continuable-inheritance.cordis.yml', import.meta.url), ) +const SUBAGENT_CONFIGURED_EFFORT_CONFIG = fileURLToPath( + new URL('../subagent-configured-effort.cordis.yml', import.meta.url), +) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) @@ -201,6 +204,10 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, overridden: true, + pinsHeader: true, + headerClass: 'session-title', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', configPath: SESSION_TITLE_CONFIG, }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, @@ -402,8 +409,8 @@ const SCENARIOS: Scenario[] = [ // An overwrite whose replacement is at/above the configured diff-basis bound: // the persisted result meta carries no contextual hunks and presentation // falls back to the whole-file diff. The overlay leaves the prompt and tool - // sequence identical to text-turn, but the freshly recorded header carries - // the current adapter capability fields, so the scenario pins its own class. + // sequence identical to text-turn. The scenario pins its own header class + // for config fields while sharing the unchanged prompt and tool schema. { name: 'fs-write-overwrite-bounded', hasModelTurn: true, @@ -559,6 +566,20 @@ const SCENARIOS: Scenario[] = [ overridden: true, configPath: DEPTH_TWO_CONFIG, }, + // Authored keyless replay first discovers the exact child route through the + // assembled directory tool, then proves a configured effort is validated + // before child creation through the same live replay LLM registry. + { + name: 'subagent-configured-effort-rejection', + hasModelTurn: true, + recorded: false, + overridden: true, + pinsHeader: true, + headerClass: 'subagent-configured-effort', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', + configPath: SUBAGENT_CONFIGURED_EFFORT_CONFIG, + }, // Authored keyless replay through the assembled app: a one-shot child calls // the real ask_user_question tool, the runtime-ownership guard rejects before // the tripwire provider, and the child carries the unresolved decision in its 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 01ba355631..607c9b3bb3 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","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":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"e3c23441-606f-4e7a-8338-b434c0d04a4e"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"b1814e62-f9de-49fc-8e60-4271eecb3500"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 73958c517c..d3cbf0e856 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","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":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"5d128e81-c7c2-4cd0-ad1c-7409b33650fc"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"f82215a6-9c52-4c75-b46b-f722a1b64f72"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 1743643d95..b880f67453 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 @@ -301,6 +301,13 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; + /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ + list_subagent_models: { + /** Registered LLM provider id. Omit to list providers. */ + provider?: string; + /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ + model?: string; + } & Record; /** 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. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -351,12 +358,18 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[]; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ 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; + /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ + provider?: string; + /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ + model?: string; + /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ + reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -587,6 +600,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + list_subagent_models: string; ralph: { runId: string; agentsStarted: number; 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 dcce863f94..2f1950a691 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 @@ -457,6 +457,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -627,7 +644,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -639,6 +656,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 37df6287ed..b667c8dd6b 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -134,6 +134,13 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; + /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ + list_subagent_models: { + /** Registered LLM provider id. Omit to list providers. */ + provider?: string; + /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ + model?: string; + } & Record; /** 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. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -184,12 +191,18 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[]; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ 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; + /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ + provider?: string; + /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ + model?: string; + /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ + reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -401,6 +414,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + list_subagent_models: string; ralph: { runId: string; agentsStarted: number; 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 b8a2ed790f..bf85198220 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -430,7 +447,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -442,6 +459,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index daf622df60..c9bad7d1fa 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -136,6 +136,13 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; + /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ + list_subagent_models: { + /** Registered LLM provider id. Omit to list providers. */ + provider?: string; + /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ + model?: string; + } & Record; /** 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. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -186,12 +193,18 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[]; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ 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; + /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ + provider?: string; + /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ + model?: string; + /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ + reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -403,6 +416,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + list_subagent_models: string; ralph: { runId: string; agentsStarted: number; 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 6894f13fb6..7506ad8373 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 @@ -136,6 +136,13 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; + /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ + list_subagent_models: { + /** Registered LLM provider id. Omit to list providers. */ + provider?: string; + /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ + model?: string; + } & Record; /** 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. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -186,12 +193,18 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[]; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ 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; + /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ + provider?: string; + /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ + model?: string; + /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ + reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -403,6 +416,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json index 993a7579bd..2819e54870 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json @@ -180,6 +180,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -313,7 +330,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -325,6 +342,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." 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 4ea490a884..c012852f0a 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "lsp", "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", @@ -446,7 +463,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -458,6 +475,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." 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 e1d954e615..5eec9bb706 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." 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 944a002e53..2d5b27c48b 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json index 0ed6e087db..bb5b4b7411 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." 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 6ab4f41978..2303325732 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." 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 c423cdb57c..7ff41194b3 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -613,7 +630,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -625,6 +642,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl index 323cdcdb4c..e6f032662c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"d8734c8a-d956-4e3f-8d28-399adf51a203"},"surfaceOp":"append"} 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 fdda53355f..6aa10aa7d5 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 @@ -323,6 +323,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -472,7 +489,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -484,6 +501,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/input.json b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/input.json new file mode 100644 index 0000000000..eb1c2ee7ff --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/replay.override.json b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/replay.override.json new file mode 100644 index 0000000000..0ac208bb71 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/replay.override.json @@ -0,0 +1,32 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_list_child_model", "name": "list_subagent_models", "argumentsDelta": "{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_list_child_model", "name": "list_subagent_models", "arguments": "{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_configured_effort", "name": "subagent", "argumentsDelta": "{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_configured_effort", "name": "subagent", "arguments": "{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}" } }, + { "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": "CONFIGURED_EFFORT_REJECTED" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "CONFIGURED_EFFORT_REJECTED" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/session.jsonl new file mode 100644 index 0000000000..25f746a2c1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/session.jsonl @@ -0,0 +1,41 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation."}],"source":{"kind":"user"},"role":"user","id":"04d53124-d38b-4705-a711-d0b5755b74c9"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation."}],"source":{"kind":"user"},"role":"user","id":"04d53124-d38b-4705-a711-d0b5755b74c9"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"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":"55365caf-6fcc-484b-a4b7-646914654bbc"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Inspect the configured child model,","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list_child_model","name":"list_subagent_models","argumentsDelta":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"000adf36-9918-4ad3-ab38-2fdb9003d0d7"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_list_child_model"},"content":[{"type":"tool-result","toolCallId":"call_list_child_model","content":[{"type":"text","text":"deepseek-official/deepseek-v4-flash — deepseek-v4-flash\nReasoning efforts:\n(no advertised reasoning efforts)"}],"isError":false}],"role":"user","id":"f480c34c-cd56-4b8e-be7c-e6ce50d3f70a"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_configured_effort","name":"subagent","argumentsDelta":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aec67548-de79-480f-8aec-2bd458ddbb41"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_configured_effort"},"content":[{"type":"tool-result","toolCallId":"call_configured_effort","content":[{"type":"text","text":"Error: provider \"deepseek-official\" model \"deepseek-v4-flash\" does not support reasoning effort \"unsupported\""}],"isError":true}],"role":"user","id":"9a59cf14-e222-40de-85f4-6a4ab4f5a177"},"error":{"name":"LlmError","code":"UNSUPPORTED_REASONING_EFFORT"}},"sourceEventSeqs":[28],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"CONFIGURED_EFFORT_REJECTED"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458f-b802-4a66221ec047"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/stdout.expected.jsonl new file mode 100644 index 0000000000..7d5829fb9d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/stdout.expected.jsonl @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_list_child_model","title":"list_subagent_models","kind":"other","status":"in_progress","rawInput":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_list_child_model","status":"completed","content":[{"type":"content","content":{"type":"text","text":"deepseek-official/deepseek-v4-flash — deepseek-v4-flash\nReasoning efforts:\n(no advertised reasoning efforts)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_configured_effort","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Validate configured effort","prompt":"This child must never start.","provider":"deepseek-official","model":"deepseek-v4-flash","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_configured_effort","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: provider \"deepseek-official\" model \"deepseek-v4-flash\" does not support reasoning effort \"unsupported\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl index 841bdb291d..d2d2f47853 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl @@ -1,5 +1,5 @@ {"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","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"sandbox/mode","data":{"mode":"read-only","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} 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 38f4eae1ad..62937be9b1 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -425,7 +442,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -437,6 +454,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index a03acfdecb..685168de6b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,5 +1,5 @@ {"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","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} 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 38f4eae1ad..62937be9b1 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -425,7 +442,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -437,6 +454,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." 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 b64494a028..d59385affa 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 @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"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"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"72272791-eefd-48f8-94da-02b132ae9d2a"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"f544ed7b-5a1f-4b6e-93b5-6af8342385fc"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Call subagent once. Ask that","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 3e3dfa357b..362fed6c4e 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 @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"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"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"c54120cc-6a7f-41f6-a71d-42b4805fa2ca"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"5d344fef-f707-49ea-b804-ac384bf52700"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Attempt one subagent call beyond","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork-in-process/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork-in-process/session.1.jsonl index 57bbed4d87..36c2654033 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork-in-process/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork-in-process/session.1.jsonl @@ -28,7 +28,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"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":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","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":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"257e572f-6f95-48f9-b3d7-4ea8b162f374"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl index b80c3d4936..2e6847126b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl @@ -1,5 +1,5 @@ {"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","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} 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 38f4eae1ad..62937be9b1 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -425,7 +442,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -437,6 +454,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl index 4de198e6f6..def81f79a2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Truncated child"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Truncated child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"885ea744-63dd-4198-95be-267b9db94a57"},"surfaceOp":"append"} 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 b64a6cfdfe..3dbae741e9 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"f216ca0e-6dcc-4ab3-9cdb-fe38d3dacca2"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"507aa273-ce20-4aaa-9a35-abaae2a5b1cf"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 e7de6e1e7d..c623192004 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -28,10 +28,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"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"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","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","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":"ac4f4d97-639d-4ad0-a513-219a58355531"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"cf2e06ce-6ea9-451a-bb75-46e59c7a78be"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," 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 e1b186cd1f..dd6140142a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"47cdc6a0-a8c8-4842-964a-ad4bc97dc76a"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"ed6eaae0-f071-44ea-9d95-d68185f87194"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","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 f8cb2e5924..4269894bfb 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","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","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":"f9132345-93c9-40c0-b489-5916bbca96bc"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"de519157-85ec-4e58-9d05-07b469aab403"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl index 14b644dc33..1d1f3f1372 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl @@ -2,19 +2,19 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fadafbc9-263b-4169-82c6-a39868629377"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"e7e63c63-ff17-4f1b-a375-9aba4b477b44"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fadafbc9-263b-4169-82c6-a39868629377"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"1d6d2982-78f7-49b9-b32d-0eb465d672b1"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"e7e63c63-ff17-4f1b-a375-9aba4b477b44"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"46bdee11-0be5-4a62-a41d-08915b210451"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9ccb6b64-4dfb-47a2-9967-13ab05483998"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e195c568-4ea2-4a14-a27c-3ab43d8000b0"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl index 5f298c76c5..e708b437fe 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl @@ -2,19 +2,19 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"dc34a17f-fb30-4afe-a11f-a0d8a1d51658"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8c91be41-04b6-4c83-a3a6-95e323c807de"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"dc34a17f-fb30-4afe-a11f-a0d8a1d51658"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"12a26f3d-f11e-4de4-8bed-d997590d21e0"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8c91be41-04b6-4c83-a3a6-95e323c807de"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"591f5521-ef20-4f12-be4a-420489c9355b"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d368f9a5-7d0a-46f0-a7d8-10e1fafa1e74"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d38d405b-30c9-46b4-a165-78ae723f172e"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} 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 dd28fc243b..e5c3fdaf72 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl @@ -1,5 +1,5 @@ {"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","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} 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 38f4eae1ad..62937be9b1 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -425,7 +442,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -437,6 +454,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl index efe72ff42b..a70c330e78 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"24630f5a-f790-469f-96a6-cf234ded3759"},"surfaceOp":"append"} 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 db3c652d58..0720890967 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." 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 b2236d44a3..630a9b086f 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 @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "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.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index a42b3cf01d..fce39e713c 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"12bbd4dd-4040-4cc7-8acf-e526144f1ee5"},"surfaceOp":"append"} diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index fa037ab935..759a252b4e 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -124,13 +124,17 @@ config: provider: spawn toolName: subagent + enableModelSelection: true 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 -# as an explicit foreground-only choice even though agent-spine-demo mounts the -# generic Job runtime. See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. +# Fork omits model selection so provider/model stay equal to the parent and the +# inherited history remains eligible for KV Cache reuse. It stays one-shot because +# a continuable child's `report` tool and prompt section precede that history and +# invalidate the same prefix. `run_in_background` is off as an explicit +# foreground-only choice even though agent-spine-demo mounts the generic Job runtime. +# See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +# and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: 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 1287de6339..6455b03e27 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","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":"c66e310e-2597-4d01-85c8-2d70a9d831c0"}]}} +{"type":"agent/inbox/spliced","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":"2324a504-7992-4dd5-b1a4-22783caf793c"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c66e310e-2597-4d01-85c8-2d70a9d831c0"},"surfaceOp":"append"} -{"type":"user/message","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":"fd0a0587-8df4-46b2-809a-317346a4c0f4"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2324a504-7992-4dd5-b1a4-22783caf793c"},"surfaceOp":"append"} +{"type":"user/message","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":"9b642d5e-ecc8-4c09-8f98-fd067b62ae63"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */\n list_subagent_models: {\n /** Registered LLM provider id. Omit to list providers. */\n provider?: string;\n /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */\n model?: 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */\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 /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */\n provider?: string;\n /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */\n model?: string;\n /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */\n reasoning_effort?: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 list_subagent_models: string;\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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","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."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"02dd8a61-a39a-46d0-8f6f-457533271cae"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","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":"3f5bbe50-ec28-482a-ad44-729f575e12e6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","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 1f18e866a9..becebf92ee 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","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":"8b3cd23c-82f1-4903-8a3f-b9082059b40c"}]}} +{"type":"agent/inbox/spliced","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":"743dc5b8-8c32-49ae-b75d-71cc45562c1a"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8b3cd23c-82f1-4903-8a3f-b9082059b40c"},"surfaceOp":"append"} -{"type":"user/message","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":"823e5037-9e96-4ef5-8c5b-cbe73b993ee2"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"743dc5b8-8c32-49ae-b75d-71cc45562c1a"},"surfaceOp":"append"} +{"type":"user/message","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":"e7caca87-00de-4018-a7cc-627620018806"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */\n list_subagent_models: {\n /** Registered LLM provider id. Omit to list providers. */\n provider?: string;\n /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */\n model?: 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */\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 /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */\n provider?: string;\n /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */\n model?: string;\n /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */\n reasoning_effort?: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 list_subagent_models: string;\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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","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."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"cd78f077-1fad-4cdc-ab56-09d39d9095cd"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","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":"c07ab6db-7108-46b4-ad18-3f81e9609546"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","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 c49d9b2745..4bed94ecda 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","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"8a0ac233-283c-4eb4-8bbd-5c50b7e99afe"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"e203969f-330f-4886-8d34-82d0001db89b"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"8a0ac233-283c-4eb4-8bbd-5c50b7e99afe"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"e203969f-330f-4886-8d34-82d0001db89b"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: 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 /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */\n list_subagent_models: {\n /** Registered LLM provider id. Omit to list providers. */\n provider?: string;\n /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */\n model?: 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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */\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 /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */\n provider?: string;\n /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */\n model?: string;\n /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */\n reasoning_effort?: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 /** 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 jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 list_subagent_models: string;\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 jobId: 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 jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"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. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","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."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e0351488-7bca-48d6-b7af-87d533858f47"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"749df5cd-1017-43c0-8c74-b2cab4752e59"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"9fd9ba62-1b6d-4bb7-98c6-97815e983026"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"57e5798f-59ce-43ee-90c2-dcf4a66de1f3"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,13 +22,13 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6f6c3cc3-350b-4afb-a3d1-8f91b9494628"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a3df67b0-ba4f-4fc3-a359-05a63f636ba9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"},"isError":false,"content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}]}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"},"isError":false,"content":[{"type":"text","text":"{\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n}"}]}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"40a17cbf-a853-4813-bbb5-7970cfbc7010"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"2e591904-ffdd-4b86-824e-a4a5667b9383"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -36,9 +36,9 @@ {"type":"assistant/chunk","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.\",\"run_in_background\":false}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bdea090-4a9a-451f-bb21-459af50472fa"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"assistant/message","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.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"93019032-9a3e-4947-aaff-4790cc42ecbb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} {"type":"tool/call","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.\",\"run_in_background\":false}"}} -{"type":"tool/result","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":"c978c208-8ebd-4dd6-b997-606ca7de787e"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","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":"3b86b42c-4989-4490-a1f4-471dc8984c00"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -46,13 +46,13 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"e55b2b2e-497c-45d2-8115-16f317ae573f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"assistant/message","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":"df7d2480-5bf2-4780-ab43-376d3f867b2a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} {"type":"tool/call","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-workflow/run-start","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","name":"advanced-headless-snapshot"}} {"type":"tool-workflow/agent-start","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} {"type":"tool-workflow/agent-end","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"outcome":"completed"}} {"type":"tool-workflow/run-end","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","stopReason":"completed"}} -{"type":"tool/result","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":"f85f58fc-8d7e-4c9f-a0fe-caff480a9fec"}},"sourceEventSeqs":[48],"surfaceOp":"append"} +{"type":"tool/result","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":"9fd8c742-bba7-4a6a-9c91-ac5d19a77eff"}},"sourceEventSeqs":[48],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -60,9 +60,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"68bd993f-a0bd-4ea8-ad16-6b1a19e09bd3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ad845a3d-0053-4991-bccc-ba09bd640699"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"2d90e3b5-2a4c-4408-a1a0-3d009786a07b"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"d21035a4-fcb1-46b5-af03-e9f9b7685444"}},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -70,6 +70,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"f3823367-2e25-43d2-a129-70dc492b2a90"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} +{"type":"assistant/message","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":"32f4ee19-e038-4c1c-9fc9-4288be6ca08f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"turn/end","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 575bbc5dfa..af15e860e3 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","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"10eb2388-2d40-4564-af27-e7a5419fc14e"}]}} +{"type":"agent/inbox/spliced","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"b187bcb5-4465-48d2-8aa1-ad12060ca048"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"10eb2388-2d40-4564-af27-e7a5419fc14e"},"surfaceOp":"append"} +{"type":"user/message","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 compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/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":"b187bcb5-4465-48d2-8aa1-ad12060ca048"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","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."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","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","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","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"a71b2cfd-c18f-4a1b-82f6-e89fb371a87e"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","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":"f6d534ce-c24d-49ee-ac3c-d05257fa03fb"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","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":"c9e68608-2dff-44bc-a344-b01006272378"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","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":"86b7cf93-1014-4334-a4af-5862c82fd5fb"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","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":"compaction/start","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"compaction/summary","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":266,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","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":"3668b957-07a2-4cb7-96b1-98a23ac8cdb8"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"user/message","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":"a4847ff0-6fa0-419b-8427-eade600c05ee"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} {"type":"compaction/end","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","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":"bcbfd4ff-60e5-4634-ae39-4de3708a8abc"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","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":"18a03939-b8df-491e-ac8a-a74938f2aa4c"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","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 6e5148b4de..a3b7e4dc17 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","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":"d35cdacd-b5e6-4968-b7a3-5ec48f403ef7"}]}} +{"type":"agent/inbox/spliced","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":"41d289fe-fc8a-464a-b3e1-db2c0189cae9"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","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":"d35cdacd-b5e6-4968-b7a3-5ec48f403ef7"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"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":"053af702-9950-4860-913a-3c7e45a54f9d"},"surfaceOp":"append"} +{"type":"user/message","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":"41d289fe-fc8a-464a-b3e1-db2c0189cae9"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"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":"21b5e1f2-ef74-44cf-820b-d2a0bf77a519"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 shell/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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","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":"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 job id for job_output/job_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 job id immediately; collect with job_output or stop with job_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","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 shell/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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_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":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"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) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","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."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","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":"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 job id for job_output/job_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 job id immediately; collect with job_output or stop with job_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","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","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","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","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"911213f8-acce-47be-a4f2-9d72ef55d83a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","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":"36ae38c0-78e0-4302-9457-dd88fd8bde7c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","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":"2da21b47-7fb3-444c-99a6-2c21743731ee"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","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":"f6583921-7e08-49f6-b5bd-99f6a3ced4b2"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"cf12d7ee-322a-4057-97b0-98d828a96f1a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","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":"000f1cda-e29a-42e1-9c7c-6e3803aa9316"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","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":"d1ffb2dc-6a33-4c9e-aeb9-b87bf6da8617"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","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":"68ac57a2-530b-4479-b278-78b8c10657d5"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"b9b99ae9-4685-4ee5-b951-fbe58f84c4e3"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","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":"c67744f1-e874-4678-8ea4-45eb96d95692"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","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":"d0d7331d-0d54-457a-9f7b-beb22abd34e6"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","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":"e77a5157-0844-48d2-9a4d-4b242c5d8357"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"9bb8ec3d-6e6f-44a2-8957-8f2d855f4834"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","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":"cc545337-43b3-4fc6-8a17-74da2a49086a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","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":"16fc1ca2-0eca-42d7-85d0-31794424c260"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","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":"3cde95e4-fcc3-422d-ae60-cc041c2cb223"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,9 +53,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"4ebdc957-0369-4bbb-a5a5-4d2ef8ac3493"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","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":"830096ca-82a9-4f44-b798-7d7040c35172"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","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":"55a3e2cc-dbc5-44bf-a824-e3bbc8568cd5"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","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":"f43c7146-3cb9-4e91-96f8-aa77b26d09e2"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -63,9 +63,9 @@ {"type":"assistant/chunk","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","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","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":"6f956d23-5437-4a75-93a9-3abacd378e07"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","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":"3070bc05-8897-4f63-8c9d-257e6b518b67"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","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":"be12d914-6fe1-4c1e-8262-65b2aa9c20e5"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","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":"bc65b492-2cef-4fe6-8476-910bdc21d4ed"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -73,6 +73,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9cca9680-5795-47d8-8edc-f6d44bcaa1ef"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ce300b5f-bfbf-4d85-8801-dce12f260d36"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"turn/end","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 index f07a82ecbc..d2ac2d31c6 100644 --- a/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl @@ -1,5 +1,5 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","origin":"subagent","delegationDepth":1} -{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"agent/inbox/spliced","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","data":{"turn":1}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl index 013d23d9ee..286ae33dcc 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -3,12 +3,12 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\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: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"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","data":{"title":"Use the write tool exactly","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl index 9efed81901..332cf1be07 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl @@ -2,6 +2,7 @@ {"type":"turn/start","data":{"turn":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"sandbox/mode","data":{"mode":"read-only"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"}},"reason":"initial"}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","data":{}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} @@ -11,16 +12,16 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -28,6 +29,6 @@ {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":2}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts index 8ca9855ed4..7b68ab4481 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -26,7 +26,7 @@ const sessionId = SessionId('subagent-inheritance-parent') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const task = 'Delegate the write probe to a subagent.' -/** Seed a completed parent turn with the only read-only fact in the app. */ +/** Seed a completed parent turn with its read-only policy and current LLM selection. */ async function seedReadOnlyParent(root: string, cwd: string): Promise { const ctx = new Context() await ctx.plugin(SessionStore) @@ -42,7 +42,22 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise { { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } }, - { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, + { + type: 'request/header', + seq: 3, + time: 13, + data: { + header: { + config: { + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('low'), + }, + }, + reason: 'initial', + }, + }, + { type: 'turn/end', seq: 4, time: 14, data: { turn: 1, reason: { kind: 'completed' } } }, ] try { await ctx.sessionPersistence.create(meta) diff --git a/examples/python-sdk-agent/cordis.yml b/examples/python-sdk-agent/cordis.yml index 6a04f5f42d..eeececff10 100644 --- a/examples/python-sdk-agent/cordis.yml +++ b/examples/python-sdk-agent/cordis.yml @@ -69,6 +69,7 @@ config: provider: spawn toolName: subagent + enableModelSelection: true enableRunInBackground: false - id: tool-todo diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 625fa2f16f..230ada0336 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -144,7 +144,9 @@ describe('Python SDK dsh profile keyless smoke', () => { }, }, }) + const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] expect(modelRequests[0]?.max_tokens).toBe(1234) + expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models') child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) diff --git a/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl b/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl index f00b85e15f..48d98ad6de 100644 --- a/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl +++ b/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl @@ -104,7 +104,7 @@ {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":3,"time":0,"data":{"turn":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/descriptor","seq":5,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/descriptor","seq":5,"time":0,"data":{"version":3,"mode":"one-shot","provider":"spawn","label":"echo probe"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"{{sessionId}}"},"surfaceOp":"append"}}} diff --git a/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl b/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl index 52aa539f93..059bded5dc 100644 --- a/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl +++ b/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"echo probe"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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":"dc291267-28a7-40f4-adac-cd856dbe0bba"},"surfaceOp":"append"} diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index da2d77ece5..aa51d159f6 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -15,6 +15,7 @@ import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controlle import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' @@ -448,7 +449,11 @@ describe('subagent ownership fence', () => { type: 'subagent/descriptor', seq: 2, time: 3, - data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, + data: snapshotSubagentDescriptor({ + mode: 'continuable', + provider: 'spawn', + label: 'child', + }), }, { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, ] as SessionEvent[] diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 65da35f554..981e791fb4 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -327,12 +327,15 @@ config: provider: spawn toolName: subagent + enableModelSelection: true 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. + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. It stays one-shot + # because a continuable child's `report` tool and prompt section precede that + # history and invalidate the same prefix. + # See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md + # and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index f22884cc77..18a0d3911a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -41,6 +41,11 @@ # `dsh.client` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: + # Host-owned opt-in sampled when a new Web session receives its preset + # delegation tools. The Models page edits this settings namespace. + - id: subagent-model-selection-settings + name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' + - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker-thread' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 2f26a9daed..68c43da265 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 0eb68f97f2..add13d5562 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-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-settings-models/README.md -README.md: cc430c91b9c4124fc70d0ec205b5870012dd5554 -README.zh.md: 62358adbc6f055697efe33e29e579f0aec64efc3 +README.md: 0daa9c5288f169138a5961e8fd6bee7818dba89b +README.zh.md: b2f80b845302d29728a0574b82f565c96acf59f2 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index cc430c91b9..0daa9c5288 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Models settings and product-onboarding plugin. The same client Cordis plugin registers the Models page plus two ordered first-run dialogs: a versioned internal-testing notice and the conditional official-DeepSeek credential step. Both steps share one modal wrapper and remain sequenced by `settings.onboarding`. The Models plane 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. +When the Host advertises the `subagent-model-selection` settings namespace, Models also renders a localized switch above the provider rows. It defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples it while composing a new top-level Session; changing it does not reconfigure running Sessions, while child Sessions inherit their parent's recorded decision. + Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. 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`/`maxTokens`; 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 notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`. On loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API; only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 62358adbc6..b2f80b8453 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -4,6 +4,8 @@ 模型设置与产品引导插件。同一个 client Cordis 插件会注册 Models 页面和两个有序的首次使用弹窗:版本化内测声明,以及按条件显示的 DeepSeek 官方凭据步骤。两个步骤共用同一套弹窗组件,并继续由 `settings.onboarding` 排序。Models 平面把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret slot)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 +Host 公布 `subagent-model-selection` settings namespace 时,Models 还会在提供方行上方渲染本地化开关。它默认关闭,只通过 `settings.update` 携带 namespace revision 写入 `{ enabled }`。Host 会在组合新的顶层 Session 时读取它;修改设置不会重新配置运行中的 Session,而子 Session 会继承父级已记录的决定。 + 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `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`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 声明步骤在 `src/client/locales.ts` 中持有完整文案,并在 `src/onboarding-copy.ts` 中持有确认版本。回环访问会通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`;只有明确点击「继续」才会记录当前版本。非回环浏览器无法使用这项仅限 Host 的 namespace,因此确认仅在当前进程有效,重载后声明会再次出现。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 3719535a3a..1c767386dd 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -40,6 +40,87 @@ color: var(--dsw-alias-state-success-primary); } +.preferenceCard { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px 16px; + margin-top: 4px; + padding: 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; +} + +.preferenceCopy { + min-width: 0; +} + +.preferenceTitle { + margin: 0; + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.preferenceDescription { + margin: 2px 0 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.switch { + box-sizing: border-box; + position: relative; + width: 36px; + height: 20px; + padding: 2px; + border: 0; + border-radius: 10px; + background: var(--dsw-alias-border-l3); + cursor: pointer; +} + +.switchOn { + background: var(--dsw-alias-brand-primary); +} + +.switch:disabled { + cursor: default; + opacity: 0.5; +} + +.switch:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); +} + +.switchThumb { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-label-primary-foreground); + transition: transform 120ms ease; +} + +.switchOn .switchThumb { + transform: translateX(16px); +} + +.preferenceStatus, +.preferenceCard > .error { + grid-column: 1 / -1; +} + +.preferenceStatus { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-success-primary); +} + .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ @@ -623,7 +704,8 @@ select.input { } @media (prefers-reduced-motion: reduce) { - .customizedSummary::before { + .customizedSummary::before, + .switchThumb { transition: none; } } diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 7f178564d6..9501185e87 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -22,6 +22,7 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor import type { ModelsSettingsStore, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' +import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -278,12 +279,24 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode { // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema) + const subagentModelSelection = state.namespaces.get('subagent-model-selection') return (

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} + {subagentModelSelection === undefined + ? null + : ( + + )} {savedIdentity === undefined ? null : ( diff --git a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx new file mode 100644 index 0000000000..3b62f28e60 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx @@ -0,0 +1,87 @@ +/** User control for model-selectable subagent delegation in new sessions. */ + +import { useState } from 'react' +import type { ReactNode } from 'react' +import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { ModelsSettingsStore } from './store.ts' +import type { en } from './locales.ts' +import { messageOf } from './store.ts' +import styles from './ModelsSection.module.css' + +/** Props for the Host-owned subagent model-selection preference. */ +export interface SubagentModelSelectionCardProps { + /** Current redacted namespace view. */ + namespace: SettingsNamespaceView + /** Whether the settings provider accepts writes. */ + writable: boolean + /** Settings wire face. */ + api: Pick + /** Models page controller to refresh after a commit. */ + controller: ModelsSettingsStore + /** Localized Models copy. */ + t: (key: keyof typeof en) => string +} + +/** Read the schema-validated resolved boolean from a namespace view. */ +function enabledOf(namespace: SettingsNamespaceView): boolean { + if (typeof namespace.value !== 'object' || namespace.value === null) return false + return (namespace.value as { enabled?: unknown }).enabled === true +} + +/** Render and persist the default-off new-session preference. */ +export function SubagentModelSelectionCard({ + namespace, + writable, + api, + controller, + t, +}: SubagentModelSelectionCardProps): ReactNode { + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(undefined) + const enabled = enabledOf(namespace) + + const toggle = (): void => { + setSaving(true) + setSaved(false) + setError(undefined) + void api.settings.update({ + ns: namespace.ns, + patch: { enabled: !enabled }, + expectedRevision: namespace.revision, + }).then(async (response) => { + if (!response.result.ok) throw new Error(response.result.error.message) + controller.acceptNamespace(response.result.value) + await controller.load() + setSaved(true) + }).catch((reason: unknown) => { + setError(messageOf(reason)) + }).finally(() => { setSaving(false) }) + } + + return ( +
+
+

+ {t('subagentModelSelectionTitle')} +

+

{t('subagentModelSelectionDescription')}

+
+ + {saved + ?

{t('subagentModelSelectionSaved')}

+ : null} + {error === undefined ? null :

{error}

} +
+ ) +} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index f1b0718ba5..176e33fe5e 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -5,6 +5,10 @@ export const en = { nav: 'Models', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', + subagentModelSelectionTitle: 'Subagent model selection', + subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', + subagentModelSelectionToggle: 'Allow subagents to choose models', + subagentModelSelectionSaved: 'Saved. New sessions use this setting.', edit: 'Edit', editProvider: 'Edit {provider}', remove: 'Delete', @@ -109,6 +113,10 @@ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', + subagentModelSelectionTitle: 'Subagent 自选模型', + subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', + subagentModelSelectionToggle: '允许 subagent 自选模型', + subagentModelSelectionSaved: '已保存,新会话将使用此设置。', edit: '编辑', editProvider: '编辑 {provider}', remove: '删除', diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 349798acdd..2b17c9fc4b 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -124,6 +124,15 @@ export class ModelsSettingsStore { private readonly describeFace: SettingsDescribeFace, ) {} + /** + * Fold one successful settings write into the shared mirror before rejoining + * this page's rows. + * @param view - namespace view returned by the settings wire method. + */ + acceptNamespace(view: SettingsNamespaceView): void { + this.describeFace.acceptView(view) + } + /** * Refresh the whole page snapshot: the provider directory and the mirror's * settings answer in parallel, then one batched credential describe over diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 8a3b8fce0a..5d0cbe8fed 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -8,6 +8,7 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-re import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' +import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { @@ -122,6 +123,14 @@ function wireNamespaces(): SettingsNamespaceView[] { secrets: [], revision: 0, }, + { + ns: 'subagent-model-selection', + schema: JSON.parse(JSON.stringify(Schema.object({ enabled: Schema.boolean().default(false) }).toJSON())) as unknown, + value: { enabled: false }, + applies: 'live', + secrets: [], + revision: 4, + }, ] } @@ -143,9 +152,10 @@ function scriptedFace(overrides: { set?: ReturnType unset?: ReturnType } = {}) { - const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) - const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) - const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) + const providerNamespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')! + const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) + const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) + const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({}))) const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({}))) const face = { @@ -236,6 +246,71 @@ describe('ModelsSection', () => { expect(document.body.textContent).toBe('') }) + it('persists the default-off subagent model-selection switch for new sessions', async () => { + const enabledNamespace: SettingsNamespaceView = { + ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, + value: { enabled: true }, + user: { enabled: true }, + revision: 5, + } + const update = vi.fn(() => Promise.resolve(ok(enabledNamespace))) + await mountSection({ update }) + + const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(toggle.getAttribute('aria-checked')).toBe('false') + fireEvent.click(toggle) + + await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) + expect(update).toHaveBeenCalledWith({ + ns: 'subagent-model-selection', + patch: { enabled: true }, + expectedRevision: 4, + }) + expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) + }) + + it('reports rejected subagent model-selection updates and permits a retry', async () => { + const update = vi.fn() + .mockResolvedValueOnce(fail('revision changed')) + .mockResolvedValueOnce(ok({ + ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, + value: { enabled: true }, + revision: 5, + })) + await mountSection({ update }) + + const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + fireEvent.click(toggle) + expect((await screen.findByRole('alert')).textContent).toBe('revision changed') + + fireEvent.click(toggle) + await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('keeps malformed and read-only subagent preferences off', () => { + const namespace = { + ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, + value: null, + } as unknown as SettingsNamespaceView + const update = vi.fn() + render( + , + ) + + const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(toggle.getAttribute('aria-checked')).toBe('false') + expect((toggle as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(toggle) + expect(update).not.toHaveBeenCalled() + }) + it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { await mountFirstRun() // Nothing is reachable yet, and DeepSeek has no configured credential and diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 2a5445de6b..88d4e6da7d 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 907bbe75fd687388be044916f4c4509e1c552177 -README.zh.md: 76a73155a3d9f811931cf0f86c227e6ab3dc1c32 +README.md: 1b233ae1203171930ef5b58de93ec67381ec4918 +README.zh.md: 81af654072f23c5280e2e14bc891972b5e1f37d5 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 907bbe75fd..1b233ae120 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -42,6 +42,7 @@ interface Config { id: string // required provider?: string model?: string + reasoningEffort?: string // non-empty initial reasoning effort maxTokens?: number // positive per-request output-token cap resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session @@ -49,7 +50,7 @@ interface Config { } ``` -Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`; it is also the whole of the `agent-loop` Settings section, so a user layer over this entry caps the next tool group without a restart, and a value that is not a positive integer is refused at the write rather than at that group. `agents` is deliberately absent from that section — it is consumed once when the service starts, so a stored change could only look like it had an effect. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. +Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional non-empty `reasoningEffort` seeds the request's reasoning setting; `agent/request` may override it, and adapter resolution validates the effective value recorded in the request header. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`; it is also the whole of the `agent-loop` Settings section, so a user layer over this entry caps the next tool group without a restart, and a value that is not a positive integer is refused at the write rather than at that group. `agents` is deliberately absent from that section — it is consumed once when the service starts, so a stored change could only look like it had an effect. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. ### Internal concrete driver diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 76a73155a3..81af654072 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -42,6 +42,7 @@ interface Config { id: string // required provider?: string model?: string + reasoningEffort?: string // non-empty initial reasoning effort maxTokens?: number // positive per-request output-token cap resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session @@ -49,7 +50,7 @@ interface Config { } ``` -通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`;它同时也是 `agent-loop` Settings 段的全部内容,因此叠加在该条目之上的用户层无需重启即可限制下一组工具调用,而非正整数的值会在写入时被拒绝,而不是到那一组时才失败。`agents` 刻意不在该段中——它在服务启动时被消费一次,所以存储的改动只会看起来生效。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。 +通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的非空 `reasoningEffort` 会提供请求的初始推理强度;`agent/request` 可以覆盖它,适配器解析会校验记录在请求 header 中的最终值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`;它同时也是 `agent-loop` Settings 段的全部内容,因此叠加在该条目之上的用户层无需重启即可限制下一组工具调用,而非正整数的值会在写入时被拒绝,而不是到那一组时才失败。`agents` 刻意不在该段中——它在服务启动时被消费一次,所以存储的改动只会看起来生效。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。 ### 包内部具体驱动器 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 3ef1ec7aa4..6bf7517903 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -438,11 +438,12 @@ export class ReactLoopAgent implements Agent { const persistedHeader = session.requestHeader() const persistedConfig = persistedHeader?.config const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' } - const reasoningEffort = persistedConfig?.provider === route.provider + const persistedReasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model && persistedHeader?.adapterDefaults?.reasoningEffort !== true ? persistedConfig.reasoningEffort : undefined + const reasoningEffort = this.options.reasoningEffort ?? persistedReasoningEffort const maxTokens = this.options.maxTokens const seedConfig = deepFreeze(structuredClone( this.requestHeaderLogged diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 371154a7c9..38ee7dfef5 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -19,7 +19,7 @@ import type { ResumeAgentOptions, SessionStartSource, } from '@deepseek-ai/dsh-agent' -import { errorChain } from '@deepseek-ai/dsh-llm' +import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' @@ -304,6 +304,7 @@ export class AgentLoop extends Service implements AgentFactory { sessionId: z.string().min(1), provider: z.string(), model: z.string(), + reasoningEffort: z.string().min(1) as z>, maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER), cwd: z.string(), resumeSessionId: z.string(), diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 2105b86f42..4082b43452 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, CallId, LlmError, ReasoningEffortId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' @@ -77,6 +77,34 @@ describe('agent loop', () => { expect(adapter.requests[0]?.maxTokens).toBe(256) }) + it('seeds an AgentOptions reasoning effort into the first model request', async () => { + const effort = ReasoningEffortId('high') + const adapter = new MockAdapter([textResponse('reasoned')], { + efforts: [{ id: effort, name: 'High' }], + defaultEffort: effort, + }) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create( + SessionId('configured-reasoning-effort'), + { provider: 'mock', model: 'mock', reasoningEffort: effort }, + ) + + send(agent, 'use the configured reasoning effort') + await waitForIdle(ctx, agent) + + expect(adapter.requests[0]?.reasoningEffort).toBe(effort) + }) + + it('validates reasoning effort in declarative agent config', () => { + const effort = ReasoningEffortId('high') + expect(AgentLoop.Config({ + agents: [{ id: 'configured-agent', reasoningEffort: effort }], + }).agents[0]?.reasoningEffort).toBe(effort) + expect(() => AgentLoop.Config({ + agents: [{ id: 'configured-agent', reasoningEffort: ReasoningEffortId('') }], + })).toThrow() + }) + it('cancels queued wakeup work together with an active maintenance task', async () => { const adapter = new MockAdapter([textResponse('park reply')]) const ctx = await harness(adapter) @@ -1429,7 +1457,11 @@ describe('agent loop', () => { }) it('creates agents from config on startup', async () => { - const adapter = new MockAdapter([textResponse('from config')]) + const effort = ReasoningEffortId('high') + const adapter = new MockAdapter([textResponse('from config')], { + efforts: [{ id: effort, name: 'High' }], + defaultEffort: effort, + }) const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) @@ -1437,7 +1469,7 @@ describe('agent loop', () => { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }], + agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', reasoningEffort: effort }], }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1451,6 +1483,9 @@ describe('agent loop', () => { send(agent, 'hi') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) + expect(adapter.requests[0]?.reasoningEffort).toBe(effort) + const header = agent.session.events.find(event => event.type === 'request/header') + expect(header?.type === 'request/header' && header.data.header.config.reasoningEffort).toBe(effort) }) it('attaches config agent cwd to the fresh session header', async () => { diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 4dd5627b2e..a9d78f3fad 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: f6b698e93b254c97786155e7d2c7e81f07c0d981 -README.zh.md: 5e3da8a5d09d06fa4fc9726406aae9c6d5e69c3e +README.md: 70b396d787de5d95332c379ff20ab92c64065857 +README.zh.md: fee72f3cd1fb456ae639d6444fe3fe914c41220a diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index f6b698e93b..70b396d787 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -14,7 +14,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installModelSelection(agentCtx, selection)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies its provider and model to prompt variables, and applies the complete selection to request routing for one step; an absent selected effort clears an inherited effort so adapter/provider defaults apply. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. -`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. +`AgentOptions` supplies the initial provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The concrete loop validates exact-model reasoning support, resolves adapter defaults, records the effective values in the request header, and applies them to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 5e3da8a5d0..fee72f3cd1 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -14,7 +14,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 -`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。具体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 +`AgentOptions` 提供初始的提供方/模型路由、可选且由适配器定义的 `reasoningEffort`,以及可选的正数 `maxTokens` 输出上限。具体循环会校验确切模型支持的推理强度、解析适配器默认值,把生效值记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 - `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber dispose。 - 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。 diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index df7449d406..3f8f7c512b 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -7,7 +7,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig, LlmFailure, ReasoningEffortId, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' import type { Inbox } from './inbox.ts' @@ -27,6 +27,8 @@ export interface AgentOptions { provider?: string /** Model id interpreted by the selected provider adapter. */ model?: string + /** Adapter-owned reasoning effort for the selected provider/model route. */ + reasoningEffort?: ReasoningEffortId /** Maximum output tokens for each conversation-model request. */ maxTokens?: number } diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index b4e7117541..c005211567 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -49,6 +49,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'step/end', 'step/start', 'subagent/descriptor', + 'subagent/model-selection-enabled', 'team/member', 'team/message/delivered', 'team/message/queued', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index ce2007c34b..38d68deae6 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -30,7 +30,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { 'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'followup_task', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', - 'list_agents', 'list_agents', 'lsp', 'pwsh', 'pwsh', 'ralph', + 'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', @@ -88,7 +88,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { // agents surface this one package as both `subagent` and `subagent_fork`. const catalog = await collectToolCatalog() const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent') - expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent']) + expect(subagent?.schemas.map(s => s.name)).toEqual(['list_subagent_models', 'subagent']) expect(subagent?.note).toMatch(/subagent_fork/) }) }) diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl index 291a18ba1e..7fd433c32a 100644 --- a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl @@ -171,7 +171,7 @@ {"type":"session/end-seed","data":{},"seq":169,"time":1787472100000} {"type":"turn/start","data":{"turn":29},"seq":170,"time":1787472100001} {"type":"user/message","data":{"id":"preview-review-user","role":"user","content":[{"type":"text","text":"Review whether the preview fixture is isolated from future WebFS data."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":171,"time":1787472100002} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Review preview architecture"},"seq":172,"time":1787472100003} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"fork","label":"Review preview architecture"},"seq":172,"time":1787472100003} {"type":"step/start","data":{"turn":29,"step":1},"seq":173,"time":1787472100004} {"type":"assistant/message","data":{"turn":29,"step":1,"message":{"id":"preview-review-assistant","role":"assistant","content":[{"type":"text","text":"The bundled fixture is static image content; future WebFS state remains user-owned."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":174,"time":1787472100005} {"type":"step/end","data":{"turn":29,"step":1},"seq":175,"time":1787472100006} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl index 6353e854c4..03b884adc6 100644 --- a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"preview-follow-up-builder","createdAt":1787472200000,"cwd":"/dsh/workspace","parentSession":"preview-showcase","origin":"subagent","delegationDepth":1,"agentPreset":"standard"} {"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472200000} {"type":"user/message","data":{"id":"preview-builder-user","role":"user","content":[{"type":"text","text":"Check that the Preview workspace can support follow-up tasks."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472200001} -{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Continue preview verification"},"seq":2,"time":1787472200002} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Continue preview verification"},"seq":2,"time":1787472200002} {"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472200003} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-builder-assistant","role":"assistant","content":[{"type":"text","text":"This child is continuable and ready for another verification turn."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":4,"time":1787472200004} {"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472200005} diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index eea3a05e2b..320c44b9aa 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -195,7 +195,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async recompose(agentCtx: Context, id: string): Promise', - description: 'Re-link one agent to a different preset\'s standing composition.\n\nOnly valid while the agent has produced nothing: swapping tools mid conversation would leave logged tool calls the new composition cannot make. The CALLER owns that check — this method does not read session history.\n\nThe swap is a parent re-link, not an unmount: standing mounts are shared and permanent, so the old composition stays for its other agents and the new one is ensured BEFORE the link moves. An unknown or unusable preset therefore throws with the agent exactly as it was — there is no torn-down state to restore. The re-link runs through the binding this roster kept from the agent\'s mount — dsh-scope\'s only re-link authority. An agent that never composed one has nothing to re-link: the switch is then the agent\'s first bind, exactly a mount.', + description: 'Re-link one agent to a different preset\'s standing composition.\n\nOnly valid while the agent has produced nothing: swapping tools mid conversation would leave logged tool calls the new composition cannot make. The CALLER owns that check — this method does not read session history.\n\nThe swap is a parent re-link, not an unmount: standing mounts are shared and permanent, so the old composition stays for its other agents and the new one is ensured BEFORE the link moves. An unknown or unusable preset therefore throws with the agent exactly as it was — there is no torn-down state to restore. The re-link runs through the binding this roster kept from the agent\'s mount — dsh-scope\'s only re-link authority. An agent that never composed one has nothing to re-link: the switch is then the agent\'s first bind, exactly a mount. A committed re-link emits `tools/change` because changing the parent scope changes the Agent\'s resolved tool set without adding or removing registry entries.', parameters: [{ name: 'agentCtx', description: 'the agent\'s scope context.' }, { name: 'id', description: 'the preset to compose the agent from instead.' }], returns: 'the preset now installed.', throws: ['when the preset is unknown or its composition is unusable.'], @@ -1906,6 +1906,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'subagentModelSelection', + summary: 'Singleton settings owner read by delegation tools when an Agent is published.', + description: 'Singleton settings owner read by delegation tools when an Agent is published.', + methods: [ + { + signature: 'currentEnabled(): boolean', + description: 'Read the preference for the next eligible Agent publication.', + parameters: [], + returns: 'whether that Agent should receive model-selectable delegation.', + }, + ], + }, { key: 'subagents', summary: 'Named provider registry with one-shot runs, durable discovery, and continuable-child operations.', @@ -3124,7 +3137,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentOptions', - declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', + declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n reasoningEffort?: ReasoningEffortId;\n maxTokens?: number;\n}', }, { name: 'AgentPreset', @@ -3416,7 +3429,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContinuableSubagentDescriptorData', - declaration: 'export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'continuable\';\n readonly label: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', + declaration: 'export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'continuable\';\n readonly label: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly agentReasoningEffort?: ReasoningEffortId;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', }, { name: 'CordisDynamicPackageId', @@ -4928,7 +4941,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentCapabilities', - declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', + declaration: 'export interface SubagentCapabilities {\n readonly agentOptions: boolean;\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', }, { name: 'SubagentDescendantListEntry', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 2d2b710299..57ecda0a84 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 0570ead15e0c408e838cab7a641293ccdb11a702 -README.zh.md: 7f58ba2b6a9be8f03fdcc4538777889780949057 +README.md: 7433bb75104506ec2409c659f3d30058abc6f9a4 +README.zh.md: 7dcdfeac17b0bfca70a293760061182292edb531 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 0570ead15e..7433bb7510 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -50,7 +50,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. +The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as the fast, economical choice for focused work, `deepseek-v4-pro` as the stronger, higher-cost choice for complex or quality-critical work, and the image-capable `deepseek-v4-flash-vision-exp`; each has a 1,000,000-token context window. An explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors, the Web selector, and model discovery tools, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: alpha images try WebP (effort 0) at 85, 75, then 60, and opaque images try JPEG at those qualities; when every quality exceeds 1MiB the smallest output is used. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by text naming the complete attachment id and actual request dimensions. When the attachment provider exposes a host object and the current filesystem maps it into the tool execution world, the text also includes that read-only path and the matching extension for a writable copy. This access is resolved independently from the deterministic request version and its `variantId`. The descriptor states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. @@ -66,7 +66,7 @@ Concurrent resolution of one scoped `variantId` shares one Files upload with wai `maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. A catalog entry may carry its own `maxTokens`, which wins for that model; an entry without one, and any unlisted pass-through id, resolve to the profile value, so adding a per-model cap changes one model rather than the route. Exact-model resolution exposes the winner as `defaultMaxTokens`; `LlmRuntime` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. -The same exact-model result exposes ordered `off`, `low`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `low`, `high`, and `max` enable thinking and serialize as the same official top-level `reasoning_effort` value; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. +The same exact-model result exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the advertised default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `low`, `high`, and `max` enable thinking and serialize as the same official top-level `reasoning_effort` value; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `low`, `high`, or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 7f58ba2b6a..7dcdfeac17 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -50,7 +50,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 +该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布适合聚焦任务、快速且经济的 `deepseek-v4-flash`,适合复杂或质量关键任务、更强但成本更高的 `deepseek-v4-pro`,以及支持图片输入的 `deepseek-v4-flash-vision-exp`;三者的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器、Web 选择器和模型发现工具等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:透明图片依次尝试质量 85、75、60 的 WebP(effort 0);非透明图片依次尝试这些质量的 JPEG。全部质量档都超过 1MiB 时使用其中最小的产物。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有文本,写明完整附件 ID 和实际请求尺寸。附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,文本还会给出该只读路径,并指出复制到可写路径时应使用的匹配扩展名。该访问方式独立于确定性的请求版本及其 `variantId`。描述也会说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 @@ -66,7 +66,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。Catalog 配置项可以自带 `maxTokens`,它对该模型胜出;不含该上限的配置项以及任何未列出原样传递 id 都解析为 profile 值,因此新增按模型的上限只改变一个模型,而非整条路由。确切模型解析会将胜出值公开为 `defaultMaxTokens`;`LlmRuntime` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 -同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`low`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`low`、`high` 和 `max` 会启用思考,并以同名值序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 +同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序且附带选择指引的 `off`、`low`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择公布的默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`low`、`high` 和 `max` 会启用思考,并以同名值序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `low`、`high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 92761bb840..35212d32d6 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -173,13 +173,33 @@ const LOW_REASONING_EFFORT = ReasoningEffortId('low') const HIGH_REASONING_EFFORT = ReasoningEffortId('high') const MAX_REASONING_EFFORT = ReasoningEffortId('max') const REASONING_EFFORTS = [ - { id: OFF_REASONING_EFFORT, name: 'Off' }, - { id: LOW_REASONING_EFFORT, name: 'Low' }, - { id: HIGH_REASONING_EFFORT, name: 'High' }, - { id: MAX_REASONING_EFFORT, name: 'Max' }, + { + id: OFF_REASONING_EFFORT, + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }, + { + id: LOW_REASONING_EFFORT, + name: 'Low', + description: 'Prefer for routine or latency-sensitive tasks.', + }, + { + id: HIGH_REASONING_EFFORT, + name: 'High', + description: 'The default balance for most tasks.', + }, + { + id: MAX_REASONING_EFFORT, + name: 'Max', + description: 'Reserve for the hardest quality-first tasks.', + }, ] as const const OFF_ONLY_REASONING_EFFORTS = [ - { id: OFF_REASONING_EFFORT, name: 'Off' }, + { + id: OFF_REASONING_EFFORT, + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }, ] as const /** Marks a failed file-id resolution that may be retried as an inline request. */ diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 0d895a5e3b..da0c9e24b5 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -82,8 +82,18 @@ const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' const PROVIDER = 'deepseek-official' const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ - { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW }, + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', + contextWindow: DEFAULT_CONTEXT_WINDOW, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + contextWindow: DEFAULT_CONTEXT_WINDOW, + }, { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9366c32ce8..3f87737930 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1205,7 +1205,11 @@ describe('DeepSeekAdapter against a mock server', () => { await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { - efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], defaultEffort: ReasoningEffortId('off'), }, }) @@ -1665,8 +1669,20 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', + inputModalities: ['text'], + }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, { provider: 'deepseek-official', id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) @@ -1678,10 +1694,10 @@ describe('plugin registration and config', () => { defaultMaxTokens: 256_000, reasoning: { efforts: [ - { id: ReasoningEffortId('off'), name: 'Off' }, - { id: ReasoningEffortId('low'), name: 'Low' }, - { id: ReasoningEffortId('high'), name: 'High' }, - { id: ReasoningEffortId('max'), name: 'Max' }, + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, ], defaultEffort: ReasoningEffortId('high'), }, @@ -1708,10 +1724,10 @@ describe('plugin registration and config', () => { .resolves.toMatchObject({ reasoning: { efforts: [ - { id: ReasoningEffortId('off'), name: 'Off' }, - { id: ReasoningEffortId('low'), name: 'Low' }, - { id: ReasoningEffortId('high'), name: 'High' }, - { id: ReasoningEffortId('max'), name: 'Max' }, + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, ], defaultEffort: ReasoningEffortId(effort), }, @@ -1729,7 +1745,11 @@ describe('plugin registration and config', () => { await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) .resolves.toMatchObject({ reasoning: { - efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], defaultEffort: ReasoningEffortId('off'), }, }) @@ -1761,7 +1781,11 @@ describe('plugin registration and config', () => { const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({ reasoning: { - efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], defaultEffort: ReasoningEffortId('off'), }, }) @@ -1772,8 +1796,20 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', + inputModalities: ['text'], + }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, { provider: 'deepseek-official', id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] }, ]) }) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 42601edbd4..a4f3fed8d6 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: e204868e43ba07b78eff31293cc33cf790ff0085 -README.zh.md: f11fa5f092b9d4de1abb5c1464b6931d749c2970 +README.md: 3e92f02518c5a36412c9448a26d32958c217f79c +README.zh.md: 4600589749a063df924f9c961cc449506ba2af9f diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index e204868e43..3e92f02518 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -16,7 +16,7 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. - `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined). - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. -- `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. +- `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. A committed re-link emits `tools/change`, because the Agent's resolved tool set changed without a registry entry changing. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. - `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it. - `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index f11fa5f092..4600589749 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -16,7 +16,7 @@ - `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 - `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步、且自身没有组装失败模式;调用方用错(上下文无 scope、agent 已加入过)仍会拒绝。 - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 -- `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 +- `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。重链提交后会发出 `tools/change`,因为 Agent 解析到的工具集已经变化、但注册表条目本身没有增删。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。 - `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index dfdf08cbbe..a20ab0a87c 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { diff --git a/packages/preset/agent-presets/presets/code/agent.cordis.yml b/packages/preset/agent-presets/presets/code/agent.cordis.yml index 3333a980c0..1302329c25 100644 --- a/packages/preset/agent-presets/presets/code/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/code/agent.cordis.yml @@ -189,8 +189,13 @@ config: provider: spawn toolName: subagent + modelSelectionSettings: true backgroundMode: continuable + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. This preset + # keeps fork continuable and accepts its child-scoped `report` additions invalidating + # that prefix; issue #2124 tracks cache-preserving continuable fork. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml index f23907c655..b016eae3a1 100644 --- a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml @@ -176,8 +176,13 @@ config: provider: spawn toolName: subagent + modelSelectionSettings: true backgroundMode: continuable + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. This preset + # keeps fork continuable and accepts its child-scoped `report` additions invalidating + # that prefix; issue #2124 tracks cache-preserving continuable fork. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/preset/agent-presets/presets/standard/agent.cordis.yml b/packages/preset/agent-presets/presets/standard/agent.cordis.yml index 5cb19e1e24..c21c5e4d79 100644 --- a/packages/preset/agent-presets/presets/standard/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/standard/agent.cordis.yml @@ -188,8 +188,13 @@ config: provider: spawn toolName: subagent + modelSelectionSettings: true backgroundMode: continuable + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. This preset + # keeps fork continuable and accepts its child-scoped `report` additions invalidating + # that prefix; issue #2124 tracks cache-preserving continuable fork. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index e22dee7275..8f44e2003c 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -27,6 +27,8 @@ import z from '@deepseek-ai/schemastery' 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' +// Type-only: resolves the registry notification emitted after scope reparenting. +import type {} from '@deepseek-ai/dsh-tools' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' @@ -455,7 +457,9 @@ export class AgentPresets extends Service { * state to restore. The re-link runs through the binding this roster kept * from the agent's mount — dsh-scope's only re-link authority. An agent * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. + * agent's first bind, exactly a mount. A committed re-link emits + * `tools/change` because changing the parent scope changes the Agent's + * resolved tool set without adding or removing registry entries. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. @@ -474,6 +478,14 @@ export class AgentPresets extends Service { } else { binding.rebind(standing.key) } + // Reparenting changes every scope-layered tool view without adding or + // removing a registration. Publish the registry's normal invalidation so + // Agent-owned overlays can reconcile with the new ancestry. + try { + this.ctx.emit('tools/change') + } catch (error: unknown) { + this.ctx.logger.warn(`agent-presets: tools/change listener failed after recomposing an Agent: ${String(error)}`) + } return preset } diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 5b22e26ec6..83f6da3102 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -484,6 +484,27 @@ describe('replacing a composition', () => { expect(toolNames(ctx)).toEqual([]) }) + it('notifies tool views after reparenting and contains notification failures', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-tool-change'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + await ctx.agentPresets.standingKeyFor('minimal') + let changes = 0 + const stopCounting = ctx.on('tools/change', () => { changes += 1 }) + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + expect(changes).toBe(1) + stopCounting() + + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const stopThrowing = ctx.on('tools/change', () => { throw new Error('listener failed') }) + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'standard')).resolves.toMatchObject({ id: 'standard' }) + expect(ctx.agentPresets.composedPreset(handle.agent.ctx)).toBe('standard') + expect(warnings).toEqual([expect.stringContaining('tools/change listener failed')]) + stopThrowing() + }) + it('leaves the agent on its previous composition when the new one is unknown', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('sess-unknown'), diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index 405ca21e2b..b40cf776ad 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -30,6 +30,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../core/tools" + }, { "path": "../../settings/settings" }, diff --git a/packages/sdk/server/tests/built-scope-carrier.e2e.ts b/packages/sdk/server/tests/built-scope-carrier.e2e.ts index 0aa7099d0b..ae4ab8ef05 100644 --- a/packages/sdk/server/tests/built-scope-carrier.e2e.ts +++ b/packages/sdk/server/tests/built-scope-carrier.e2e.ts @@ -66,7 +66,7 @@ try { const result = Promise.withResolvers(); const unregister = ctx.subagents.registerProvider({ name: "built-local", - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start() { return Promise.resolve({ diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index d9527e10d5..257c3e72b0 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -79,7 +79,7 @@ async function settleSubagent( const result = Promise.withResolvers() const disposeProvider = ctx.subagents.registerProvider({ name: info.provider, - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, async start() { return { @@ -538,7 +538,7 @@ describe('HarnessSdkJsonRpcServer', () => { let currentLocalAgent = oldChild.agent const disposeProvider = ctx.subagents.registerProvider({ name: 'reused', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start() { const result = results[starts] @@ -633,7 +633,7 @@ describe('HarnessSdkJsonRpcServer', () => { const remoteResult = Promise.withResolvers() const unregisterLocal = ctx.subagents.registerProvider({ name: 'reused-provider', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => Promise.resolve({ id: SessionId('provider-reuse-child'), @@ -651,7 +651,7 @@ describe('HarnessSdkJsonRpcServer', () => { const unregisterRemote = ctx.subagents.registerProvider({ name: 'reused-provider', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => Promise.resolve({ id: SessionId('provider-reuse-child'), @@ -731,7 +731,7 @@ describe('HarnessSdkJsonRpcServer', () => { const missedStartResult = Promise.withResolvers() const disposeMissedStartProvider = ctx.subagents.registerProvider({ name: 'fork', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: true, start: () => Promise.resolve({ id: SessionId('fallback-child-session'), diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index fa56c715a5..73f55ccfbf 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-acp/README.md -README.md: 0201f9bacca5c59031d2fc2eec7fe7210489dd09 -README.zh.md: c987e6251e035f0cf94ac41b570f3be10f0118f5 +README.md: cc4deb5b97152f106caabf747b8c7ccb2f5ddf8e +README.zh.md: e28fb556801d4567bcc606a777e3b090e0551e03 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 0201f9bacc..cc4deb5b97 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -18,7 +18,7 @@ After publication, the provider sends the prompt and collects streamed `agent_me ## Capabilities and context -ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above — no conversation context crosses the process boundary. +ACP advertises no start-time capabilities because this process cannot apply `request.agentOptions` or enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above — no conversation context crosses the process boundary. ## Configuration @@ -70,7 +70,7 @@ The package has no default export. Cordis loader unwrapping would otherwise hide #### What the model sees -The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. +The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for `agentOptions`, persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. #### Token effect @@ -98,6 +98,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **A fresh process per run** — persistent-process pooling is a future optimization ([the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)). - **Local workspaces only** — the resolved cwd is a local path handed to a child on the same machine; workspace mapping for a remote ACP agent would need its own backend capability and is not designed here. -- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them. +- **No optional start-time capabilities** — this provider cannot apply the local harness's `agentOptions`, `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them. - **Only committed `agent_message_chunk` text is collected** — the automation server keeps reasoning, tool activity, plans, and other trace data in the child session log rather than emitting them on ACP. - **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission`. diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index c987e6251e..e28fb55680 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -18,7 +18,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s ## 能力与上下文 -ACP 不声明任何启动时能力,因为当前进程无法强制执行远程子 agent 的深度、工具过滤、persona 或结构化输出运行时。它也报告 `inheritsParentContext: false`:远程会话从全新状态开始,唯一源自父级的输入是上述工作区 cwd;对话上下文不会跨越进程边界。 +ACP 不声明任何启动时能力,因为当前进程无法应用 `request.agentOptions`,也无法强制执行远程子 agent 的深度、工具过滤、persona 或结构化输出运行时。它也报告 `inheritsParentContext: false`:远程会话从全新状态开始,唯一源自父级的输入是上述工作区 cwd;对话上下文不会跨越进程边界。 ## 配置 @@ -70,7 +70,7 @@ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `D #### 模型看到的内容 -远程子 agent 通过 ACP 接收独立任务内容,并使用其自身进程配置的系统提示词、工具和全新会话。它不接收父级对话。该提供方不声明任何可选启动时能力,因此本地服务会拒绝要求 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 +远程子 agent 通过 ACP 接收独立任务内容,并使用其自身进程配置的系统提示词、工具和全新会话。它不接收父级对话。该提供方不声明任何可选启动时能力,因此本地服务会拒绝要求 `agentOptions`、persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 #### Token 影响 @@ -98,6 +98,6 @@ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `D - **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md))。 - **仅支持本地工作区**:解析后的 cwd 是交给同一台机器上子进程的本地路径;远程 ACP agent 的工作区映射需要独立的后端能力,此处尚未设计这种能力。 -- **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。 +- **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `agentOptions`、`outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。 - **只收集已提交的 `agent_message_chunk` 文本**:自动化服务器把推理(reasoning)、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。 - **权限提示自动回答**(`permission: allow | reject`):不会把子 agent 的 `session/request_permission` 呈现给人。 diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 4b526279ba..4c610ef2f9 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -140,11 +140,17 @@ function resolveCwd(configured: string | undefined, request: SubagentStartReques /** * The ACP provider. Advertises NO start-time capabilities: an out-of-process - * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects - * a request needing any of them before `start` runs). + * child cannot honor `agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/ + * `persona` (the service rejects a request needing any before `start` runs). */ class AcpProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } + readonly capabilities: SubagentCapabilities = { + agentOptions: false, + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + } // Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary. readonly inheritsParentContext = false diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index c534b8e949..d4553d8f86 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -863,7 +863,13 @@ describe('dsh-subagent-acp', () => { it('advertises no start-time capabilities (out-of-process child)', async () => { const ctx = await setup() const provider = ctx.subagents.getProvider('acp')! - expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false }) + expect(provider.capabilities).toEqual({ + agentOptions: false, + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index ff129c60e2..f894fb15ab 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 0260d9c82dee82541e5b60ff7fe2c323cf9b7331 -README.zh.md: 484241f1ca23f2b8ee843b6f412d0f779832d400 +README.md: 30b61347dfda110fe95a9153aeea35c8f760012f +README.zh.md: 17ddac7d4beebfbf0f374f606a68d77b13ec2b77 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 0260d9c82d..30b61347df 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -20,7 +20,7 @@ Each query sets `persistSession: false` and disables `AskUserQuestion`. Except i ## Capabilities and context -The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session. +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. The shared service rejects `request.agentOptions` for this provider. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session. ## Configuration @@ -145,5 +145,5 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **The SDK platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first query; there is no host-CLI fallback. - **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime. -- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No optional shared capabilities** — `agentOptions`, output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 484241f1ca..17ddac7d4b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -20,7 +20,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 能力与上下文 -本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。 +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。共享服务会拒绝本提供方的 `request.agentOptions`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。 ## 配置 @@ -145,5 +145,5 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **委派时必须存在 SDK 平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次 query 时失败;不会回退到宿主 CLI。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 -- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝 `agentOptions`、输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index 5f339ce22e..6054370615 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -45,6 +45,7 @@ describe('product-provider public Loader composition', () => { { name: 'codex', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -55,6 +56,7 @@ describe('product-provider public Loader composition', () => { { name: 'claude-code', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -65,6 +67,7 @@ describe('product-provider public Loader composition', () => { { name: 'claude-primary', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -75,6 +78,7 @@ describe('product-provider public Loader composition', () => { { name: 'claude-secondary', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 0f8ed31eab..fd0cccc02d 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: 975f353b9f1bc6fab61a4c0eb40ebaf50c436623 -README.zh.md: 2ea256afb3bb8fdfe57fd555b6db10522a709a56 +README.md: 5016d6b9aa4b57c9d1e83701a0ccfc4b04616f6d +README.zh.md: adabd942c362a28a4eeabe7a4d1b27b138aee4c6 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 975f353b9f..5016d6b9aa 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -18,7 +18,7 @@ Local cancellation wins the result race and maps to `aborted`. For failed turns, ## Capabilities and context -The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session. +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. The shared service rejects `request.agentOptions` for this provider. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session. ## Configuration @@ -139,5 +139,5 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime. -- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No optional shared capabilities** — `agentOptions`, output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 2ea256afb3..adabd942c3 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -18,7 +18,7 @@ ## 能力与上下文 -本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。 +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。共享服务会拒绝本提供方的 `request.agentOptions`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。 ## 配置 @@ -139,5 +139,5 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。 -- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝 `agentOptions`、输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index b55b819989..c411ae2dbf 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -45,6 +45,7 @@ describe('Codex provider public Loader composition', () => { { name: 'codex', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -55,6 +56,7 @@ describe('Codex provider public Loader composition', () => { { name: 'codex-primary', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -65,6 +67,7 @@ describe('Codex provider public Loader composition', () => { { name: 'codex-secondary', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index 8946de4236..a05ec421e3 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md -README.md: 10f437f1618d006f58ad37701e6fde7944f5c1fb -README.zh.md: d3237d9a0b36ce63c9eacf85bfe7cff947acd9e0 +README.md: 302baa05afed2b78c2041f57b1ef900713e350cd +README.zh.md: e4e1460274170b0c990d9e454f1cbf54546676e9 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 10f437f161..302baa05af 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -20,7 +20,7 @@ The SDK client returns an owned child activity rather than a prompt result. The ## Capabilities and context -The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`/`persona` all false) and `inheritsParentContext: false`: the child is a fresh runtime in another process, and the only parent-derived input is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget. +The provider advertises no start-time capabilities (`agentOptions`/`outputSchema`/`depthLimit`/`toolFilter`/`persona` all false) and `inheritsParentContext: false`: the child is a fresh runtime in another process, and the only parent-derived input is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget. ## Configuration @@ -68,7 +68,7 @@ The package has no default export. Cordis loader unwrapping would otherwise hide #### What the model sees -The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. +The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for `agentOptions`, persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. #### Token effect @@ -95,6 +95,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **A fresh runtime process per run** — no pooling; a harness runtime boots a full plugin tree, so per-run spawn cost is higher than the ACP backend's typical child. -- **No optional start-time capabilities** — the parent cannot enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead. +- **No optional start-time capabilities** — the parent cannot apply `agentOptions` or enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead. - **The child's transcript stays in the child's own session root** — the parent log records only the delegation tool call/result (the seam's child-isolation rule); the streamed `session.event` channel is consumed for output extraction, not bridged into the parent log. - **Local child processes only** — the resolved cwd is a local path; a remote runtime would need its own backend. diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index d3237d9a0b..e4e1460274 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -20,7 +20,7 @@ SDK 客户端返回自有子活动,而不是提示词结果。提供方读取 ## 能力与上下文 -Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilter`/`persona` 全为 false),且 `inheritsParentContext: false`:子进程是另一进程里的全新运行时,唯一来自父方的输入是工作区 cwd。基于本 provider 的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。 +Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLimit`/`toolFilter`/`persona` 全为 false),且 `inheritsParentContext: false`:子进程是另一进程里的全新运行时,唯一来自父方的输入是工作区 cwd。基于本 provider 的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。 ## 配置 @@ -68,7 +68,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte #### 模型看到的内容 -子运行时的模型会收到作为用户消息的独立任务,以及该运行时自身配置的系统提示词、工具和全新会话。它不会收到父级对话。本提供方不声明可选的启动时能力,因此本地服务会拒绝要求 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 +子运行时的模型会收到作为用户消息的独立任务,以及该运行时自身配置的系统提示词、工具和全新会话。它不会收到父级对话。本提供方不声明可选的启动时能力,因此本地服务会拒绝要求 `agentOptions`、persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 #### Token 影响 @@ -95,6 +95,6 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte ## 已知限制与暂缓事项 - **每次运行都使用全新的运行时进程**:不使用进程池;harness 运行时需要启动完整的插件树,因此每次运行的 spawn 成本高于 ACP 后端通常使用的子进程。 -- **不支持可选的启动时能力**:父级无法在子进程内强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。 +- **不支持可选的启动时能力**:父级无法在子进程内应用 `agentOptions`,也无法强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。 - **子进程的 transcript(文本记录)保留在其自身的会话根目录中**:父级日志只记录委派工具调用/结果(seam 的子级隔离规则);流式 `session.event` 通道只用于提取输出,不会桥接到父级日志中。 - **仅支持本地子进程**:解析出的 cwd 是本地路径;远程运行时需要独立的后端。 diff --git a/packages/subagent/subagent-dsh-sdk/src/index.ts b/packages/subagent/subagent-dsh-sdk/src/index.ts index 7530e104d2..277add74f8 100644 --- a/packages/subagent/subagent-dsh-sdk/src/index.ts +++ b/packages/subagent/subagent-dsh-sdk/src/index.ts @@ -105,7 +105,7 @@ function resolveConfiguredFile(field: string, value: string): string { /** * The SDK provider. Advertises NO start-time capabilities: an out-of-process - * child cannot honor `outputSchema`/`maxDepth`/`toolFilter`/`persona` (the + * child cannot honor `agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/`persona` (the * service rejects a request needing any of them before `start` runs). */ class SdkSubagentProvider implements SubagentProvider { diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index 2e67c76c24..27511670cf 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -433,6 +433,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { expect(ctx.subagents.getProvider('sdk-hmr')?.name).toBe('sdk-hmr') expect(ctx.subagents.getProvider('sdk-hmr')?.inheritsParentContext).toBe(false) expect(ctx.subagents.getProvider('sdk-hmr')?.capabilities).toEqual({ + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, diff --git a/packages/subagent/subagent-fork-in-process/README.i18n.yaml b/packages/subagent/subagent-fork-in-process/README.i18n.yaml index 9cd2361d52..19af9e598a 100644 --- a/packages/subagent/subagent-fork-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-fork-in-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-fork-in-process/README.md -README.md: 74c27ff10c76aa711ed3e954e806c00a27aacfa5 -README.zh.md: 43e7ef489b33d52b674420d08f7edf8c89fb0e42 +README.md: c2dcda39c03b8c059839bef1573436e485a51911 +README.zh.md: 3eb84053a3e74abe7394c0985dcd88bd6146e449 diff --git a/packages/subagent/subagent-fork-in-process/README.md b/packages/subagent/subagent-fork-in-process/README.md index 74c27ff10c..c2dcda39c0 100644 --- a/packages/subagent/subagent-fork-in-process/README.md +++ b/packages/subagent/subagent-fork-in-process/README.md @@ -16,7 +16,7 @@ The seed transfers conversation history only. The child still receives a fresh f `start(request)` passes the completed-turn seed to [`startInProcessRun`](../subagent-in-process-driver/README.md) and awaits child publication. The shared driver owns cancellation, depth, customization, result reading, and disposal. -Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`, identical to spawn. +Fork advertises `{ agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`, identical to spawn. ## Config @@ -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. 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)). +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 base bundle and ACP/headless examples 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 CLI presets retain `continuable` fork and therefore accept that prefix loss ([the cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)). ### Parent tool result, indirectly @@ -58,4 +58,5 @@ 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). +- **Fork lifecycle policy differs by composition** — the base bundle and ACP/headless examples use one-shot fork to preserve prefix reuse, while the CLI presets use continuable fork and accept the child-scoped [`report` return channel](../tool-subagent-report/README.md) invalidating that prefix. Making continuable fork cache-preserving requires the child system prompt and tool schemas to match the parent's byte for byte. Rationale and the reintroduction condition: [the cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md). +- **Shipped fork tools do not expose child LLM route selection** — they inherit the parent's provider and model so the copied history remains eligible for KV Cache reuse. Route selection stays disabled until a change can preserve reuse or expose a bounded recomputation cost; the [model-selected route Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md) owns that separate restriction. diff --git a/packages/subagent/subagent-fork-in-process/README.zh.md b/packages/subagent/subagent-fork-in-process/README.zh.md index 43e7ef489b..3eb84053a3 100644 --- a/packages/subagent/subagent-fork-in-process/README.zh.md +++ b/packages/subagent/subagent-fork-in-process/README.zh.md @@ -16,7 +16,7 @@ subagent 启动时,父 agent 当前的工具调用轮次仍未结束:其日 `start(request)` 将已完成轮次的初始内容传给 [`startInProcessRun`](../subagent-in-process-driver/README.zh.md),并等待子 agent 发布。共享驱动器负责取消、深度、定制、结果读取和 dispose(资源释放)。 -fork 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,与 spawn 相同。 +fork 声明 `{ agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,与 spawn 相同。 ## 配置 @@ -39,7 +39,7 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 #### KV Cache 影响 -在提供方和模型相同的前提下,子 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.zh.md))。 +在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。base 组合包与 ACP/headless 示例把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效。CLI preset 保留可继续 fork,因此接受这项前缀损失(见[保留缓存的 fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md))。 ### 父 agent 工具结果(间接) @@ -58,4 +58,5 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 ## 已知限制与暂缓事项 - **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。 -- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.zh.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 +- **fork 生命周期策略因组合而异**:base 组合包与 ACP/headless 示例使用一次性 fork 以保留前缀复用,CLI preset 则使用可继续 fork,并接受子级作用域的 [`report` 返回通道](../tool-subagent-report/README.zh.md)使该前缀失效。要让可继续 fork 保留缓存,子 agent 的系统提示词与工具 schema 必须与父级逐字节一致。理由与重新开放条件见[保留缓存的 fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 +- **随附 fork 工具不公开子级 LLM 路由选择**:它们会继承父级的提供方与模型,使复制的历史仍可供 KV Cache 复用。只有在路由变化仍能保留复用,或接口能公开一项有界的重算成本时,才启用路由选择;该独立限制由[模型选择路由 Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md)负责。 diff --git a/packages/subagent/subagent-fork-in-process/src/index.ts b/packages/subagent/subagent-fork-in-process/src/index.ts index 1f8e48b8c8..9786f585fa 100644 --- a/packages/subagent/subagent-fork-in-process/src/index.ts +++ b/packages/subagent/subagent-fork-in-process/src/index.ts @@ -55,11 +55,18 @@ function completedTurnPrefix(parent: Agent): SessionEvent[] { /** * The fork provider. Supports `depthLimit` and `outputSchema` (via the shared - * in-process structured runtime), plus `toolFilter`/`persona` (scoped - * restrict() and a scoped shadowing persona section). + * in-process structured runtime), `agentOptions` (merged over the parent + * route), and `toolFilter`/`persona` (scoped restrict() and a scoped shadowing + * persona section). */ class ForkInProcessProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } + readonly capabilities: SubagentCapabilities = { + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + } // Context contract: a forked child IS seeded with the parent's completed-turn prefix. readonly inheritsParentContext = true @@ -74,11 +81,11 @@ class ForkInProcessProvider 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 + // TODO(fork-continuable-prefix-reuse): CLI presets call this and accept that + // a continuable child's `report` tool and prompt section precede the inherited + // history, defeating the prefix reuse a fork exists for. Cache-preserving + // continuable fork needs 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 diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 8b69711233..292fba32cc 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -193,9 +193,15 @@ describe('dsh-subagent-fork-in-process', () => { await run.dispose() }) - it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => { + it('advertises every start-time capability', async () => { const { ctx } = await setup([]) - expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }) + expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { diff --git a/packages/subagent/subagent-in-process-driver/README.i18n.yaml b/packages/subagent/subagent-in-process-driver/README.i18n.yaml index 505c480cfe..16d07d8211 100644 --- a/packages/subagent/subagent-in-process-driver/README.i18n.yaml +++ b/packages/subagent/subagent-in-process-driver/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-in-process-driver/README.md -README.md: 47a5c09fc1c80c5dc3062be82e7355b874a627d3 -README.zh.md: b96399795a0fbac05ef1795888aa93c620687f30 +README.md: ed2568fcff3fe1f0f3968d1cef43ebd914a8911b +README.zh.md: f9958e5c2b819d51bfdf8fc1e14d1f8c7c19be91 diff --git a/packages/subagent/subagent-in-process-driver/README.md b/packages/subagent/subagent-in-process-driver/README.md index 47a5c09fc1..ed2568fcff 100644 --- a/packages/subagent/subagent-in-process-driver/README.md +++ b/packages/subagent/subagent-in-process-driver/README.md @@ -16,7 +16,7 @@ The driver follows this sequence: 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. 5. Read the child's own output — its last non-empty assistant message (an empty-content message that records usage is skipped), or its accumulated assistant text when no such message exists — and the final durable turn reason from the complete owned child run, excluding any fork seed. -The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. +The child gets the parent's working-directory/session lineage and inherits the parent provider, model, reasoning effort, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. diff --git a/packages/subagent/subagent-in-process-driver/README.zh.md b/packages/subagent/subagent-in-process-driver/README.zh.md index b96399795a..f9958e5c2b 100644 --- a/packages/subagent/subagent-in-process-driver/README.zh.md +++ b/packages/subagent/subagent-in-process-driver/README.zh.md @@ -16,7 +16,7 @@ 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 -子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 +子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型、推理强度与输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 8840e8b101..6bb18f5f28 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -70,7 +70,7 @@ async function setup(script: Script, options: SetupOptions = {}) { await ctx.plugin(SubagentRuntime) const disposeProvider = ctx.subagents.registerProvider({ name: 'spawn', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, inheritsParentContext: false, start: (request: ResolvedSubagentStartRequest) => startInProcessRun(request, {}), }) diff --git a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml index 1246456cc0..022dbfa58d 100644 --- a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-spawn-in-process/README.md -README.md: f1fb96f2230359cb3ff55c630f29fd34345dbed7 -README.zh.md: 95a3b5cdb7084eb75666f8d62001221c57ac676c +README.md: ebe2b069dc56dc1a3359f8880860a5796ef3ef4c +README.zh.md: 66ecbec2c00865d16a99f1e6bf4f0c32cb6e5538 diff --git a/packages/subagent/subagent-spawn-in-process/README.md b/packages/subagent/subagent-spawn-in-process/README.md index f1fb96f223..ebe2b069dc 100644 --- a/packages/subagent/subagent-spawn-in-process/README.md +++ b/packages/subagent/subagent-spawn-in-process/README.md @@ -6,13 +6,13 @@ The spawn provider creates a fresh child `Agent` in the current process. The chi ## Behavior -`start(request)` delegates to [`startInProcessRun`](../subagent-in-process-driver/README.md) with no seed and awaits publication before returning. The child receives parent working-directory/session lineage and inherits the parent model unless overridden, but starts with an empty conversation. +`start(request)` delegates to [`startInProcessRun`](../subagent-in-process-driver/README.md) with no seed and awaits publication before returning. The child receives parent working-directory/session lineage and inherits the parent provider, model, reasoning effort, and output-token limit unless `request.agentOptions` overrides them, but starts with an empty conversation. The shared driver owns depth checking, persona and tool-filter setup, structured output, required-signal cancellation, one-shot execution, result reading, and quiescent disposal. A startup rejection leaves no published child; provider unload after fulfillment does not revoke the holder-owned run. ## Capabilities -Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` because it controls the child's creation window and can enforce all four features. +Spawn advertises `{ agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` because it controls the child's creation window and can enforce all five features. ## Config @@ -26,7 +26,7 @@ Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, pers #### What the model sees -The fresh child receives the standalone task content verbatim, inherits the parent model and workspace by default, and sees the global prompt with any configured child-scoped persona shadow. A tool filter removes global wire schemas, executable lookup, and Code Mode SDK bindings for that child but leaves independently registered guidance. It receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent. +The fresh child receives the standalone task content verbatim, inherits the parent provider, model, reasoning effort, output-token limit, and workspace by default, and sees the global prompt with any configured child-scoped persona shadow. A tool filter removes global wire schemas, executable lookup, and Code Mode SDK bindings for that child but leaves independently registered guidance. It receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent. #### Token effect @@ -52,4 +52,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Fresh means no parent transcript** — the child inherits cwd, lineage, model, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required. +- **Fresh means no parent transcript** — the child inherits cwd, lineage, provider, model, reasoning effort, output-token limit, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required. diff --git a/packages/subagent/subagent-spawn-in-process/README.zh.md b/packages/subagent/subagent-spawn-in-process/README.zh.md index 95a3b5cdb7..66ecbec2c0 100644 --- a/packages/subagent/subagent-spawn-in-process/README.zh.md +++ b/packages/subagent/subagent-spawn-in-process/README.zh.md @@ -6,13 +6,13 @@ spawn 提供方会在当前进程中创建一个全新的子 `Agent`。子 agent ## 行为 -`start(request)` 不传入 seed,直接委托给 [`startInProcessRun`](../subagent-in-process-driver/README.zh.md),并在子 agent 发布后才返回。子 agent 获得父 agent 的工作目录/会话谱系,并默认继承父 agent 模型(除非覆盖),但以空对话开始运行。 +`start(request)` 不传入 seed,直接委托给 [`startInProcessRun`](../subagent-in-process-driver/README.zh.md),并在子 agent 发布后才返回。子 agent 获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型、推理强度与输出 token 上限,但以空对话开始运行。 共享驱动器负责深度检查、persona 与工具过滤器设置、结构化输出、通过必需的信号执行取消、单次执行、结果读取和完全停稳后的 dispose(资源释放)。启动遭拒不会留下已发布的子 agent;启动调用兑现后卸载提供方,也不会撤销由持有方拥有的运行。 ## 能力 -spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,因为它控制子 agent 的创建窗口,能够强制执行全部四项功能。 +spawn 声明 `{ agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,因为它控制子 agent 的创建窗口,能够强制执行全部五项功能。 ## 配置 @@ -26,7 +26,7 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: #### 模型看到的内容 -全新的子 agent 逐字接收独立任务内容,默认继承父 agent 的模型和工作区,并看到带有已配置子 agent 作用域 persona 遮蔽的全局提示词。工具过滤器会为该子 agent 移除全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但保留独立注册的指导内容。它不接收任何父 agent 对话消息;过滤控制的是可见性与组合,并非从父 agent 继承的权限授予。 +全新的子 agent 逐字接收独立任务内容,默认继承父 agent 的提供方、模型、推理强度、输出 token 上限与工作区,并看到带有已配置子 agent 作用域 persona 遮蔽的全局提示词。工具过滤器会为该子 agent 移除全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但保留独立注册的指导内容。它不接收任何父 agent 对话消息;过滤控制的是可见性与组合,并非从父 agent 继承的权限授予。 #### Token 影响 @@ -52,4 +52,4 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: ## 已知限制与暂缓事项 -- **全新表示不含父 agent transcript(文本记录)**:子 agent 会继承 cwd、谱系、模型及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。 +- **全新表示不含父 agent transcript(文本记录)**:子 agent 会继承 cwd、谱系、提供方、模型、推理强度、输出 token 上限及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。 diff --git a/packages/subagent/subagent-spawn-in-process/src/index.ts b/packages/subagent/subagent-spawn-in-process/src/index.ts index dcd036e4ad..73811155c1 100644 --- a/packages/subagent/subagent-spawn-in-process/src/index.ts +++ b/packages/subagent/subagent-spawn-in-process/src/index.ts @@ -34,12 +34,18 @@ export const Config: z = z.object({ /** * The spawn provider. Supports every start-time capability: `depthLimit` (it * constructs the child, so it can enforce a recursion cap), `outputSchema` - * (the scoped structured runtime), and `toolFilter`/`persona` (scoped - * `restrict()` and a scoped shadowing persona section, applied in the child's - * creation window). + * (the scoped structured runtime), `agentOptions` (merged over the parent + * route), and `toolFilter`/`persona` (scoped `restrict()` and a scoped + * shadowing persona section, applied in the child's creation window). */ class SpawnInProcessProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } + readonly capabilities: SubagentCapabilities = { + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + } // Context contract: a spawned child starts fresh — it never sees the parent conversation. readonly inheritsParentContext = false diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index ae60480c02..7a798a4818 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -282,10 +282,16 @@ describe('dsh-subagent-spawn-in-process', () => { await parentHandle.dispose() }) - it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => { + it('advertises every start-time capability', async () => { const { ctx } = await setup([]) const provider = ctx.subagents.getProvider('spawn')! - expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }) + expect(provider.capabilities).toEqual({ + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index a645351bd8..a55a61ee77 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: e84a6b486253e81ccf7e7df12c4149e6df4ed9f2 -README.zh.md: e289863531c1686cedeccadfa76e2661dfa9bfc8 +README.md: 68ddc49197bcbd3f8eb5f362de60da33cb08c147 +README.zh.md: cf434152cd6366e371eef86f0edcb08d18978c66 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e84a6b4862..68ddc49197 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -26,7 +26,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. | | `listDescendants(rootSessionId, signal?)` | Flatten the root's complete session tree in stable pre-order from the same live-preferred corpus, adding each subagent entry's durable `parentId` and root-relative `depth`. Ordinary sessions and one-shot children remain traversal nodes so continuable descendants below them are discovered. Identity, diagnostics, dependencies, and cancellation follow `listChildren()`. | -`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. +`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also override the host Agent's provider, model, reasoning effort, and token limit, require structured output, cap delegation depth, restrict child tools, or set a child persona. Every requested optional feature requires its matching provider capability. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up records who supplied the delivered message and grants no authority. @@ -36,11 +36,14 @@ Same-process requests, descriptors, results, and event payloads are trusted type Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported one-shot request before child creation: +- `agentOptions` — apply host-Agent provider, model, reasoning-effort, and output-token overrides. - `outputSchema` — enforce a structured final result. - `depthLimit` — enforce `maxDepth`. - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. +Both in-process providers advertise `agentOptions`: child creation merges requested fields over the provider, model, and reasoning effort in the parent's latest logged request, falling back to its creation options before the first request and retaining its configured token limit. A route change without an explicit effort clears the inherited route-owned effort so the selected model resolves its default. Current out-of-process providers advertise it as unsupported, so configured or model-selected overrides fail before their child transport starts instead of being silently ignored. + Every in-process child is composed by one call, `applyChildComposition(childCtx, parent, composition)`, which joins the parent's agent-preset composition before applying the child's own persona and tool filter. The join is what gives the child its capabilities: with every model-facing row on the agent plane, a child that joined nothing would reach the model with an empty tool registry ([`dsh-agent-presets`](../../preset/agent-presets/README.md)). Taking the parent as a parameter is deliberate — it makes composing a child WITHOUT that join unrepresentable at the call sites, which is the defect the one call exists to prevent. A deployment composing no preset roster joins nothing and needs nothing: its model-facing rows sit in the host composition, where the child already resolves them through the tool registry's global layer. `childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. @@ -49,7 +52,7 @@ Continuable creation is the optional `SubagentProvider.prepareContinuable?()` me ## The durable descriptor -The Service Definition owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the provider name and lifecycle `mode`. A `one-shot` descriptor optionally carries the caller-owned durable display `label`; a `continuable` descriptor requires its durable creation label and additionally records resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an Activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime. +The Service Definition owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the provider name and lifecycle `mode`. A `one-shot` descriptor optionally carries the caller-owned durable display `label`; a `continuable` descriptor requires its durable creation label and additionally records resolved child `agentOptions.provider`/`model`/`reasoningEffort` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an Activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime. ## Delegation depth diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index e289863531..cf434152cd 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -26,7 +26,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `listChildren(parentSessionId, signal?)` | 按 `createdAt`、再按 id 的顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、根据 origin 分类得出的一层 `hasChildren` 提示,以及每个子级的诊断信息,且不会加载或恢复它们。该操作直接读取在线会话存储和可选的会话持久化(没有持久化时只枚举在线子级),并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 | | `listDescendants(rootSessionId, signal?)` | 从同一份在线优先语料按稳定 pre-order 展平根的完整会话树,并为每个 subagent 条目附加持久 `parentId` 与相对根的 `depth`。普通会话与一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现。身份、diagnostic、依赖与取消约定均沿用 `listChildren()`。 | -`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只负责 inbox 接受前的查找、物化和准入;此后,Activation 由管理器独立拥有,因此调用方取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。 +`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以覆盖宿主 Agent 的提供方、模型、推理强度与 token 上限、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。每个被请求的可选特性都要求匹配的提供方能力。对于可继续启动或后续操作,调用方信号只负责 inbox 接受前的查找、物化和准入;此后,Activation 由管理器独立拥有,因此调用方取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。 后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 记录谁提供了所投递的消息,不授予任何权限。 @@ -36,11 +36,14 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的一次性请求: +- `agentOptions`:应用宿主 Agent 提供方、模型、推理强度与输出 token 上限覆盖; - `outputSchema`:强制执行结构化最终结果; - `depthLimit`:强制执行 `maxDepth`; - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 +两个进程内提供方都会声明 `agentOptions`:创建子 agent 时,请求字段会覆盖父级最新记录请求中的提供方、模型与推理强度;首个请求之前回退到其创建选项,并保留其中配置的 token 上限。更换路由但没有显式指定强度时,会清除继承的路由所属强度,使所选模型解析自己的默认值。当前进程外提供方会声明不支持,因此配置或模型选择的覆盖会在启动子传输前失败,而不会被静默忽略。 + 每个进程内子 agent 都通过一次 `applyChildComposition(childCtx, parent, composition)` 调用完成组装:先加入父级的 agent-preset 组合,再应用子 agent 自己的 persona 和工具限制。加入父级组合正是子 agent 获得能力的途径:所有面向模型的行都位于 agent 平面,完全没有加入任何组合的子 agent 抵达模型时会看到空的工具注册表(见 [`dsh-agent-presets`](../../preset/agent-presets/README.zh.md))。将父级作为参数是刻意设计:这让“组装子 agent 却不做该加入”在各调用点无法表达,而这正是这一次调用所要杜绝的缺陷。未组装 preset roster 的部署不加入任何组合、也不需要加入;其面向模型的行位于宿主组合中,子 agent 已能通过工具注册表的全局层解析到它们。 `childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 @@ -49,7 +52,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 持久化描述符 -该 Service Definition 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个带有提供方名称与生命周期 `mode` 的描述符。`one-shot` 描述符可以携带调用方拥有的可选持久化显示 `label`;`continuable` 描述符要求其持久化创建标签,并另外记录已解析的子 agent `agentOptions.provider`/`model`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次 Activation 的结果约定)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩(compaction)保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。 +该 Service Definition 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个带有提供方名称与生命周期 `mode` 的描述符。`one-shot` 描述符可以携带调用方拥有的可选持久化显示 `label`;`continuable` 描述符要求其持久化创建标签,并另外记录已解析的子 agent `agentOptions.provider`/`model`/`reasoningEffort`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次 Activation 的结果约定)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩(compaction)保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。 ## 委派深度 diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index 7582338858..22c9e77bf5 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -57,9 +57,38 @@ export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): } /** - * Resolve the child's `AgentOptions`: the parent's provider/model/maxTokens - * route unless the request overrides it, stamped with the child's own - * delegation depth. + * Resolve the parent values inherited by a child. The latest request header + * owns provider, model, and reasoning effort after request-time selection; + * creation options remain the fallback before the first request and retain + * the configured output-token limit. + * @param parent - delegating parent Agent. + * @returns detached Agent options for child-option merging. + */ +export function parentAgentOptionsForDelegation(parent: Agent): AgentOptions { + const requestConfig = parent.session.requestHeader()?.config + if (requestConfig === undefined) return { ...parent.options } + const { + provider: _createdProvider, + model: _createdModel, + reasoningEffort: _createdReasoningEffort, + ...createdOptions + } = parent.options + return { + ...createdOptions, + provider: requestConfig.provider, + model: requestConfig.model, + ...requestConfig.reasoningEffort === undefined + ? {} + : { reasoningEffort: requestConfig.reasoningEffort }, + } +} + +/** + * Resolve the child's `AgentOptions`: the parent's provider/model, + * reasoning-effort, and maxTokens values unless the request overrides them, + * stamped with the child's own delegation depth. Changing the route without + * naming an effort clears the parent's route-owned effort so the selected + * model resolves its own default. * @param parent - the delegating parent whose route the child inherits. * @param requested - per-child overrides, if any. * @param childDepth - the resolved delegation depth to stamp. @@ -70,16 +99,22 @@ export function resolveChildAgentOptions( requested: AgentOptions | undefined, childDepth: number, ): AgentOptions { - const parentProvider = parent.options.provider - const parentModel = parent.options.model - const parentMaxTokens = parent.options.maxTokens - return { + const parentOptions = parentAgentOptionsForDelegation(parent) + const parentProvider = parentOptions.provider + const parentModel = parentOptions.model + const parentReasoningEffort = parentOptions.reasoningEffort + const parentMaxTokens = parentOptions.maxTokens + const resolved: AgentOptions = { ...parentProvider !== undefined ? { provider: parentProvider } : {}, ...parentModel !== undefined ? { model: parentModel } : {}, + ...parentReasoningEffort !== undefined ? { reasoningEffort: parentReasoningEffort } : {}, ...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {}, ...requested, subagentDepth: childDepth, } + const routeChanged = resolved.provider !== parentProvider || resolved.model !== parentModel + if (routeChanged && requested?.reasoningEffort === undefined) delete resolved.reasoningEffort + return resolved } /** diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 652a3ba6c8..2588c1a699 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -30,7 +30,7 @@ import type { AgentSetupCommit, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' -import { boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { ReasoningEffortId, 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' @@ -417,14 +417,17 @@ export class SubagentContinuationManager { const childDepth = resolveChildDepth(parent, request.maxDepth) // Snapshot before any await: invalid descriptor JSON rejects the call // before a child exists, and the detached value is what reaches the log. - const agentProvider = request.agentOptions?.provider ?? parent.options.provider - const agentModel = request.agentOptions?.model ?? parent.options.model + const agentOptions = resolveChildAgentOptions(parent, request.agentOptions, childDepth) + const agentProvider = agentOptions.provider + const agentModel = agentOptions.model + const agentReasoningEffort = agentOptions.reasoningEffort const descriptor = snapshotSubagentDescriptor({ mode: 'continuable', provider: spec.provider, label: spec.label, ...agentProvider !== undefined ? { agentProvider } : {}, ...agentModel !== undefined ? { agentModel } : {}, + ...agentReasoningEffort !== undefined ? { agentReasoningEffort } : {}, ...request.persona !== undefined ? { persona: request.persona } : {}, ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) @@ -460,7 +463,7 @@ export class SubagentContinuationManager { provider: spec.provider, parent, create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies }, - agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), + agentOptions, composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, }) @@ -981,6 +984,9 @@ export class SubagentContinuationManager { agentOptions: { ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + ...descriptor.agentReasoningEffort !== undefined + ? { reasoningEffort: ReasoningEffortId(descriptor.agentReasoningEffort) } + : {}, }, composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, signal: options.signal, diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 6d9dedee75..9a25c382b1 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -23,6 +23,7 @@ import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' declare module '@deepseek-ai/dsh-session/types' { @@ -44,7 +45,7 @@ declare module '@deepseek-ai/dsh-session/types' { * Supporting another composition input is a deliberate version change, never * an implicit extra field. */ -export const SUBAGENT_DESCRIPTOR_VERSION = 2 +export const SUBAGENT_DESCRIPTOR_VERSION = 3 /** Fields shared by every supported `subagent/descriptor` payload. */ interface SubagentDescriptorBase { @@ -76,6 +77,8 @@ export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBas readonly agentProvider?: string /** Resolved child `agentOptions.model`, when one was declared. */ readonly agentModel?: string + /** Resolved child `agentOptions.reasoningEffort`, when one was declared. */ + readonly agentReasoningEffort?: ReasoningEffortId /** Per-child persona that shadows the deployment persona on resume. */ readonly persona?: string /** Child tool scoping reapplied on resume. */ @@ -111,6 +114,8 @@ export interface ContinuableSubagentDescriptorInput extends SubagentDescriptorIn readonly agentProvider?: string /** Requested child `agentOptions.model`. */ readonly agentModel?: string + /** Requested child `agentOptions.reasoningEffort`. */ + readonly agentReasoningEffort?: ReasoningEffortId /** Requested per-child persona. */ readonly persona?: string /** Requested child tool scoping. */ @@ -133,6 +138,7 @@ const CONTINUABLE_DESCRIPTOR_KEYS = new Set([ ...DESCRIPTOR_BASE_KEYS, 'agentProvider', 'agentModel', + 'agentReasoningEffort', 'persona', 'toolFilter', ]) @@ -231,6 +237,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef } const agentProvider = optionalString(value, 'agentProvider') const agentModel = optionalString(value, 'agentModel') + const agentReasoningEffort = optionalString(value, 'agentReasoningEffort') as ReasoningEffortId | undefined const persona = optionalString(value, 'persona') const toolFilter = Object.hasOwn(value, 'toolFilter') ? parseToolFilter(value['toolFilter']) @@ -242,6 +249,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef label, ...agentProvider !== undefined ? { agentProvider } : {}, ...agentModel !== undefined ? { agentModel } : {}, + ...agentReasoningEffort !== undefined ? { agentReasoningEffort } : {}, ...persona !== undefined ? { persona } : {}, ...toolFilter !== undefined ? { toolFilter } : {}, } @@ -283,6 +291,7 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba label: input.label, ...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {}, ...input.agentModel !== undefined ? { agentModel: input.agentModel } : {}, + ...input.agentReasoningEffort !== undefined ? { agentReasoningEffort: input.agentReasoningEffort } : {}, ...input.persona !== undefined ? { persona: input.persona } : {}, ...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {}, } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 2f29e32010..42dca84908 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -103,6 +103,7 @@ export { applyChildComposition, captureDelegatedPolicyOverrides, childSessionMeta, + parentAgentOptionsForDelegation, resolveChildAgentOptions, resolveChildDepth, SubagentDepthError, @@ -494,6 +495,7 @@ export class SubagentRuntime extends Service { /** Reject the first requested capability that the provider lacks. */ private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void { const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [ + { when: request.agentOptions !== undefined, cap: 'agentOptions' }, { when: request.outputSchema !== undefined, cap: 'outputSchema' }, { when: request.maxDepth !== undefined, cap: 'depthLimit' }, { when: request.toolFilter !== undefined, cap: 'toolFilter' }, diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index abb6dd50e7..2667884af4 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -44,10 +44,11 @@ function limitSubagentDiagnostic(diagnostic: string): string { /** * The capability advertisement of an out-of-process backend: NONE. A child in * another process cannot honor parent-enforced start features - * (`outputSchema`/`maxDepth`/`toolFilter`/`persona`), so the service rejects a + * (`agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/`persona`), so the service rejects a * request needing any of them before `start` runs — never accepted-then-ignored. */ export const NO_START_CAPABILITIES: SubagentCapabilities = Object.freeze({ + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 17978550ab..415379ca75 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -84,6 +84,7 @@ export interface SubagentRunEndInfo { * to `maxDepth`; the other names match. */ export interface SubagentCapabilities { + readonly agentOptions: boolean readonly outputSchema: boolean readonly depthLimit: boolean readonly toolFilter: boolean @@ -116,6 +117,12 @@ export interface SubagentStartRequest { * remaining turn work when it fires afterward. */ readonly signal: AbortSignal + /** + * Optional host-Agent provider, model, reasoning-effort, and output-token + * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process + * providers merge them over the parent Agent's options when they create the + * child. + */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects diff --git a/packages/subagent/subagent/tests/child-agent.spec.ts b/packages/subagent/subagent/tests/child-agent.spec.ts new file mode 100644 index 0000000000..92302cfca4 --- /dev/null +++ b/packages/subagent/subagent/tests/child-agent.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { resolveChildAgentOptions } from '../src/child-agent.ts' + +function parentAgent(): Agent { + const id = SessionId('parent') + return { + id, + options: { + provider: 'parent-provider', + model: 'parent-model', + reasoningEffort: ReasoningEffortId('high'), + maxTokens: 512, + }, + session: Session.create(id), + } as Agent +} + +describe('child Agent options', () => { + it('inherits the parent effort while the exact route is unchanged', () => { + expect(resolveChildAgentOptions(parentAgent(), undefined, 1)).toEqual({ + provider: 'parent-provider', + model: 'parent-model', + reasoningEffort: 'high', + maxTokens: 512, + subagentDepth: 1, + }) + }) + + it('clears an inherited effort when the child route changes', () => { + expect(resolveChildAgentOptions(parentAgent(), { model: 'child-model' }, 1)).toEqual({ + provider: 'parent-provider', + model: 'child-model', + maxTokens: 512, + subagentDepth: 1, + }) + }) + + it('keeps an explicit child effort when the child route changes', () => { + expect(resolveChildAgentOptions(parentAgent(), { + provider: 'child-provider', + model: 'child-model', + reasoningEffort: ReasoningEffortId('max'), + }, 1)).toEqual({ + provider: 'child-provider', + model: 'child-model', + reasoningEffort: 'max', + maxTokens: 512, + subagentDepth: 1, + }) + }) + + it('inherits the latest logged request selection over creation-time values', () => { + const parent = parentAgent() + parent.session.append('request/header', { + header: { + config: { + provider: 'current-provider', + model: 'current-model', + reasoningEffort: ReasoningEffortId('low'), + }, + }, + reason: 'initial', + }) + + expect(resolveChildAgentOptions(parent, undefined, 1)).toEqual({ + provider: 'current-provider', + model: 'current-model', + reasoningEffort: 'low', + maxTokens: 512, + subagentDepth: 1, + }) + }) +}) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 6cf1aea5a8..d1d40e0ff4 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -12,7 +12,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -239,7 +239,7 @@ describe('SubagentRuntime.startContinuable', () => { const start = vi.fn(async () => { throw new Error('must not dispatch') }) ctx.subagents.registerProvider({ name: 'one-shot', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start, }) @@ -283,6 +283,40 @@ describe('SubagentRuntime.startContinuable', () => { expect(loaded.meta.origin).toBe('subagent') }) + it('persists a selected reasoning effort and reapplies it on cold resume', async () => { + const effort = ReasoningEffortId('max') + const adapter = new MockAdapter([ + textResponse('first answer'), + textResponse('resumed answer'), + ], { + efforts: [{ id: effort, name: 'Max' }], + defaultEffort: effort, + }) + const { ctx, parent } = await setupWith(adapter) + parkParent(ctx, parent) + const childEfforts: Array = [] + ctx.on('agent/created', ({ agent }) => { + if (agent !== parent) childEfforts.push(agent.options.reasoningEffort) + }) + + const started = await ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { + prompt: message('selected reasoning'), + parent, + agentOptions: { reasoningEffort: effort }, + }, + }) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) + .toMatchObject({ agentReasoningEffort: 'max' }) + + await followup(ctx, parent, started.childId, message('resume selected reasoning')) + await waitNoActivation(ctx, started.childId) + expect(childEfforts).toEqual(['max', 'max']) + }) + it('rolls the child back completely when the caller signal aborts before acceptance', async () => { const { ctx, parent } = await setup([textResponse('unused')]) const controller = new AbortController() @@ -531,7 +565,7 @@ describe('SubagentRuntime.followup residency routing', () => { await ctx.plugin(SubagentInvariant) const disposeProvider = ctx.subagents.registerProvider({ name: 'retired', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('one-shot start is not used') }, prepareContinuable: () => Promise.resolve({}), @@ -2418,27 +2452,43 @@ describe('continuable errors', () => { hold.resolve(undefined) }) - it('reapplies the descriptor model route on cold resume', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed')]) + it('reapplies the descriptor model route and reasoning effort on cold resume', async () => { + const effort = ReasoningEffortId('high') + const adapter = new MockAdapter([textResponse('first'), textResponse('resumed')], { + efforts: [{ id: effort, name: 'High' }], + defaultEffort: effort, + }) + const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable({ ...startSpec(parent), request: { prompt: message('routed work'), parent, - agentOptions: { provider: 'mock', model: 'child-model' }, + agentOptions: { provider: 'mock', model: 'child-model', reasoningEffort: effort }, }, }) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) - .toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' }) + .toMatchObject({ + agentProvider: 'mock', + agentModel: 'child-model', + agentReasoningEffort: 'high', + }) // The resumed Activation runs on the declared route, not the parent's. await followup(ctx, parent, started.childId, message('again')) await vi.waitFor(() => { - expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model') + expect(ctx.agents.get(started.childId)?.options).toMatchObject({ + model: 'child-model', + reasoningEffort: 'high', + }) }) await waitNoActivation(ctx, started.childId) + const resumed = await ctx.sessionPersistence.load(started.childId) + expect(resumed.events.flatMap(event => event.type === 'request/header' + ? [event.data.header.config.reasoningEffort] + : [])).toEqual([effort, effort]) }) it('unloading the manager drains its live activations', async () => { diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts index 91200abf8d..08792c2348 100644 --- a/packages/subagent/subagent/tests/invariant.spec.ts +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -21,7 +21,7 @@ async function setup(): Promise { const provider = (name: string): SubagentProvider => ({ name, - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('not used') }, }) diff --git a/packages/subagent/subagent/tests/out-of-process.spec.ts b/packages/subagent/subagent/tests/out-of-process.spec.ts index 0d3307ca51..98b670f36f 100644 --- a/packages/subagent/subagent/tests/out-of-process.spec.ts +++ b/packages/subagent/subagent/tests/out-of-process.spec.ts @@ -21,7 +21,13 @@ import { describe('NO_START_CAPABILITIES', () => { it('advertises nothing and is frozen (shared by every out-of-process backend)', () => { - expect(NO_START_CAPABILITIES).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false }) + expect(NO_START_CAPABILITIES).toEqual({ + agentOptions: false, + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }) expect(Object.isFrozen(NO_START_CAPABILITIES)).toBe(true) }) }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 05e9785611..6a93f2fdef 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentRuntime, { foldSubagentDescriptor, @@ -24,8 +24,8 @@ function fakeParent(id = 'parent-1'): Agent { return { id: SessionId(id) } as unknown as Agent } -const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } -const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } +const ALL_CAPS: SubagentCapabilities = { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true } +const NO_CAPS: SubagentCapabilities = { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false } function baseRequest(overrides: Partial = {}): SubagentStartRequest { return { @@ -162,6 +162,7 @@ describe('SubagentRuntime', () => { }) it.each([ + ['agentOptions', { agentOptions: { model: 'child-model' } }], ['outputSchema', { outputSchema: { type: 'object', properties: {} } }], ['depthLimit', { maxDepth: 1 }], ['toolFilter', { toolFilter: { deny: ['bash'] } }], @@ -348,6 +349,7 @@ describe('subagent descriptors', () => { label: 'complete child', agentProvider: 'deepseek', agentModel: 'chat', + agentReasoningEffort: ReasoningEffortId('high'), persona: 'reviewer', toolFilter: { allow: ['read'], deny: ['bash'] }, } @@ -357,6 +359,7 @@ describe('subagent descriptors', () => { label: complete.label, agentProvider: complete.agentProvider, agentModel: complete.agentModel, + agentReasoningEffort: complete.agentReasoningEffort, persona: complete.persona, toolFilter: complete.toolFilter, })).toEqual(complete) @@ -448,6 +451,13 @@ describe('subagent descriptors', () => { label: 'l', agentModel: [], }, 'agentModel must be a string'], + ['invalid agent reasoning effort', { + version: SUBAGENT_DESCRIPTOR_VERSION, + mode: 'continuable', + provider: 'spawn', + label: 'l', + agentReasoningEffort: 7, + }, 'agentReasoningEffort must be a string'], ['invalid persona', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 29d65cf9da..f6caeb1941 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: 28e6213b903ffffa7934e244b2a74ada519b32b2 -README.zh.md: 74e8896a152c787abd0aebf055d6e13f6158bcd3 +README.md: e643442de7fa45f15a5c2bf818e2c25feb44b6c5 +README.zh.md: aa6dec73c66ce6b4db525d09cd166e671dbec9dc diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 28e6213b90..e643442de7 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -6,7 +6,11 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C ## Provider selection and lifecycle -Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. +Each plugin instance binds one subagent transport `provider` to one `toolName`; the model cannot change that transport. Load another distinctly named instance to expose another transport. `enableModelSelection: true`, or an enabled Host preference when `modelSelectionSettings: true`, requires that provider's child `agentOptions` capability and exposes optional child LLM `provider`, `model`, and `reasoning_effort` fields without additional route configuration. A call may supply a complete provider/model pair, or only an effort when configured or parent values supply the effective route. The live adapter resolves explicit or configured routes before child creation. A call that omits every selection field uses `agentOptions` and then inherits compatible missing values from the parent's latest logged request selection, falling back to its creation options before the first request and retaining its configured `maxTokens`. Changing provider or model without naming an effort clears the lower layer's route-owned effort so the selected model resolves its default. + +The delegation tool registers only while its subagent provider exists, avoiding sibling load-order and provider-reload dependencies. When model selection is enabled, its optional fields remain visible without `ctx.llm`; a call that selects a route rejects if the service is unavailable. When disabled, the schema omits those fields and execution rejects a forced selection. Configured `agentOptions` remain deployment-owned child defaults independently of this model-facing switch. Adapter catalog and topology changes do not rewrite or re-register the tool. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. + +An enabled definition registers `list_subagent_models`, which lists registered providers, one provider's advertised models, or one exact model's reasoning efforts at call time. At most one instance in a tool scope may enable selection because this discovery tool has a global name; duplicate owners fail registration. Shipped product compositions default the primary `subagent` (`spawn`) instance off and sample the Host `subagent-model-selection.enabled` preference when each new top-level session is composed. The enabled decision is logged as `subagent/model-selection-enabled`, inherited by child sessions, and retained on resume; later settings edits do not change a running session. Shipped compositions deliberately keep `subagent_fork` disabled so the fork inherits the parent's provider and model: changing that route would forfeit provider-side KV Cache reuse of the inherited conversation prefix and can make prefix recomputation dominate the delegated task's cost. This restriction remains even if discovery ownership is separated. Catalog membership remains advisory: an enabled delegation tool accepts an unlisted model id when its adapter does. The [model-selected route Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md) owns the rationale and reintroduction condition. 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 contains the stop-reason headline, an optional provider-authored `SubagentResult.diagnostic`, and then any preserved partial assistant text. The diagnostic remains separate from `SubagentResult.output`, so a truncated answer is never reported as success or confused with infrastructure detail. If result collection and disposal both reject, the errored result preserves both failures. @@ -20,9 +24,11 @@ A foreground call passes the execution signal through startup and execution, awa |---|---| | `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). | | `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. | +| `enableModelSelection` | Exposes and accepts model-facing child LLM selection fields and registers the shared `list_subagent_models` tool, default `false`. It requires the subagent provider's `agentOptions` capability. At most one instance in a tool scope may enable it; the discovery schema remains registered without `ctx.llm`, while discovery and selected-route calls reject until that optional service is available. Configured `agentOptions` remain available when this switch is disabled. | +| `modelSelectionSettings` | Samples the Host `subagent-model-selection` preference while composing an Agent, records an enabled decision in its Session, and inherits that decision in child Sessions. Default `false`; mutually exclusive with `enableModelSelection` and valid only in an Agent-scoped composition. The preference defaults off and changes only subsequently composed top-level Sessions. | | `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. | | `backgroundMode` | Background lifecycle policy, default `one-shot`. `one-shot` defaults calls to foreground; `continuable` defaults them to background, requires the provider's `prepareContinuable` capability, and returns a durable child id without requiring the follow-up tool. | -| `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. | +| `agentOptions` | Configured child LLM `provider`, `model`, adapter-owned `reasoningEffort`, and positive `maxTokens`; requires the subagent provider's `agentOptions` capability. In-process providers merge explicit values over the parent's latest logged request selection, or its creation options before the first request. An inherited effort survives only while the effective provider/model route is unchanged; changing the route without an explicit effort lets the selected model supply its default. A configured provider, model, or effort is checked through the optional `ctx.llm` service before child creation even when the call omits model-selection fields; a missing service or invalid value rejects the call. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | | `maxDepth` | Absolute delegation-depth cap, default `3` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap for an out-of-process provider whose budget belongs to the child harness. The tool stays visible at the cap; each attempted start checks the calling agent's current depth and returns an errored tool result when rejected. | @@ -37,15 +43,29 @@ Foreground and background calls are concurrency-safe: sibling delegations in one #### 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`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the job id collected with `job_output` or stopped with `job_kill`. While the tool is visible in an assembly's scope, a `tool:` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result; a tool restriction removes both its schema and this guidance. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. `enableModelSelection` adds `provider`, `model`, and `reasoning_effort` plus inheritance and selection guidance; the provider must support `agentOptions`. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the job id collected with `job_output` or stopped with `job_kill`. While the tool is visible in an assembly's scope, a `tool:` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result; a tool restriction removes both its schema and this guidance. #### Token effect -Fixed schema cost per parent request; each provider instance adds one schema, and each continuable instance adds one short system-prompt section. +Fixed schema cost per parent request; enabling model selection adds three parameters. Each subagent provider instance adds one schema, and each continuable instance adds one short system-prompt section. #### KV Cache effect -Prefix-stable while provider instances, names, descriptions, and schemas are unchanged. Provider registration lifecycle may invalidate parent reuse from the first changed tool definition. +Prefix-stable while subagent provider instances and their configuration are unchanged. Adapter catalog changes do not alter the definition. A route override on an inheritance-capable instance may prevent the child from reusing the inherited parent prefix. + +### Model selection and discovery + +#### What the model sees + +An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session decision is enabled, exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. With no arguments the discovery tool returns registered provider ids and names; with `provider` it returns that adapter's advertised models; with `provider` and `model` it resolves the exact model and returns its advertised reasoning efforts and default. The result is read-only runtime metadata, not an authorization list. + +#### Token effect + +One fixed tool schema is present in shipped compositions. Directory contents enter the transcript only when the model calls the tool. + +#### KV Cache effect + +The schema is prefix-stable across adapter registration and catalog changes. Each result is appended after the reusable prefix. ### Foreground result @@ -79,4 +99,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **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 any final assistant message, but it is not this call's return value and cannot be awaited here. - **Duplicate names across waiting one-shot instances are detected late** (`TODO(subagent-dup-toolname)`) — continuable instances reserve their prompt-section name during plugin application, but preventing provider-registration rollback for waiting one-shot instances 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. +- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable the fields only when route changes preserve reuse or expose a bounded recomputation cost. +- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM provider/model/reasoning-effort selection requires static enablement or an enabled per-Session preference and a subagent provider that advertises `agentOptions`; out-of-process providers currently reject enabling it rather than ignore it. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 74e8896a15..aa6dec73c6 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -6,7 +6,11 @@ ## 提供方选择与生命周期 -每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 +每个插件实例把一个 subagent 传输 `provider` 绑定到一个 `toolName`;模型不能改变该传输。如需公开另一种传输,请加载另一个名称不同的实例。`enableModelSelection: true`,或 `modelSelectionSettings: true` 时已启用的 Host 偏好,都要求该提供方具备子级 `agentOptions` 能力,并且无需额外路由配置即可公开可选的子 agent LLM `provider`、`model` 与 `reasoning_effort` 字段。调用可以提供完整的提供方/模型对;当配置值或父 Agent 值能够提供生效路由时,也可以只提供推理强度。实时 adapter 会在创建子 agent 前解析显式或配置的路由。完全省略选择字段的调用使用 `agentOptions`,再从父 Agent 最新记录的请求选择中继承兼容的缺失值;首个请求之前回退到其创建选项,并保留其中配置的 `maxTokens`。如果更换提供方或模型但没有指定强度,则清除下层路由所属的强度,使所选模型解析自己的默认值。 + +委派工具只在其 subagent 提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。启用模型选择时,即使没有 `ctx.llm`,可选字段仍然可见;选择路由的调用会在该服务缺失时失败。禁用时,schema 会省略这些字段,执行阶段也会拒绝强制传入的选择。配置的 `agentOptions` 仍是部署方所有的子级默认值,不受这个面向模型的开关影响。adapter 目录和拓扑变化不会改写或重新注册工具。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 + +启用的定义会注册 `list_subagent_models`,它会在调用时列出已注册提供方、某个提供方公布的模型,或某个精确模型的推理强度。因为发现工具使用全局名称,一个工具作用域最多只能由一个实例启用选择;多个持有方会使注册失败。随附产品组合默认关闭主 `subagent`(`spawn`)实例,并在每个新的顶层会话完成组合时读取 Host 的 `subagent-model-selection.enabled` 偏好。启用决定记录为 `subagent/model-selection-enabled`,由子会话继承并在恢复时保留;之后修改设置不会改变运行中的会话。组合会刻意在 `subagent_fork` 上保持禁用,使 fork 继承父级的提供方与模型:更改该路由会失去继承对话前缀的提供方侧 KV Cache 复用,重新计算前缀的成本可能超过委派任务本身。即使分离发现工具的持有权,该限制也仍然成立。目录条目仍只提供建议:如果适配器接受未列出的模型 ID,启用选择的委派工具也会接受。理由与重新开放条件由[模型选择路由 Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md)负责。 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本。中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息依次包含终止原因标题、可选的提供方 `SubagentResult.diagnostic`,以及子 agent 保留下来的部分 assistant 文本。诊断与 `SubagentResult.output` 保持分离,因此被截断的回答不会被报告为成功,也不会与基础设施说明混淆。如果结果收集与 dispose(资源释放)都 reject,出错结果会保留两项失败。 @@ -20,9 +24,11 @@ |---|---| | `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | | `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | +| `enableModelSelection` | 公开并接受面向模型的子级 LLM 选择字段,同时注册共享的 `list_subagent_models` 工具;默认为 `false`。它要求 subagent 提供方具备 `agentOptions` 能力。一个工具作用域最多只能由一个实例启用;即使没有 `ctx.llm`,发现 schema 仍保持注册,而发现调用和所选路由调用会在该可选服务可用前失败。禁用此开关时仍可配置 `agentOptions`。 | +| `modelSelectionSettings` | 组合 Agent 时读取 Host 的 `subagent-model-selection` 偏好,把启用决定记录进其 Session,并让子 Session 继承该决定。默认为 `false`;与 `enableModelSelection` 互斥,且只能用于 Agent 作用域组合。该偏好默认关闭,只影响之后组合的新顶层 Session。 | | `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | | `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`one-shot` 默认前台调用;`continuable` 默认后台调用,要求提供方具备 `prepareContinuable` 能力,并返回持久化子 agent ID,且不要求加载后续消息工具。 | -| `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 | +| `agentOptions` | 配置的子 agent LLM `provider`、`model`、adapter 自有 `reasoningEffort` 与正整数 `maxTokens`;要求 subagent 提供方具备 `agentOptions` 能力。进程内提供方把显式值合并到父 Agent 最新记录的请求选择之上;首个请求之前则合并到其创建选项之上。只有生效提供方/模型路由不变时才会保留继承的推理强度;改变路由但不显式提供强度时,由所选模型提供默认值。即使调用省略模型选择字段,配置的提供方、模型或强度也会在创建子 agent 前通过可选 `ctx.llm` 服务进行校验;服务缺失或值无效都会拒绝调用。 | | `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | | `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | | `maxDepth` | 绝对委派深度上限,默认 `3`(`0` 禁止委派);数值上限要求 `depthLimit` 能力,缺失时挂载失败。对于预算由子 harness 拥有的进程外提供方,`'provider-managed'` 不发送上限。工具在达到上限时仍然可见;每次尝试启动都会检查调用 agent 的当前深度,被拒绝时返回出错的工具结果。 | @@ -37,15 +43,29 @@ #### 模型看到的内容 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。`enableModelSelection` 会添加 `provider`、`model` 与 `reasoning_effort`,以及继承和选择指引;提供方必须支持 `agentOptions`。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 #### Token 影响 -每个父级请求都会产生固定的 schema token 开销;每个提供方实例增加一个 schema,每个可继续实例还会增加一个简短的系统提示词 section。 +每个父级请求都会产生固定的 schema token 开销;启用模型选择会增加三个参数。每个 subagent 提供方实例增加一个 schema,每个可继续实例还会增加一个简短的系统提示词 section。 #### KV Cache 影响 -只要提供方实例、名称、描述和 schema 不变,前缀就保持稳定。提供方注册生命周期可能从首个变化的工具定义开始,使父级复用失效。 +只要 subagent 提供方实例及其配置不变,前缀就保持稳定。adapter 目录变化不会改变定义。具备继承能力的实例如果覆盖路由,可能阻止子 agent 复用继承的父级前缀。 + +### 模型选择与发现 + +#### 模型看到的内容 + +静态配置 `enableModelSelection: true` 的实例,或 Session 决定为启用的 settings 控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。无参数调用发现工具会返回已注册提供方的 ID 和名称;提供 `provider` 时返回该适配器公布的模型;同时提供 `provider` 和 `model` 时解析精确模型,并返回其公布的推理强度和默认值。结果是只读的运行时元数据,不是授权列表。 + +#### Token 影响 + +随附组合会包含一个固定工具 schema。只有模型调用该工具时,目录内容才会进入 transcript。 + +#### KV Cache 影响 + +adapter 注册和目录变化不会改变 schema 的前缀稳定性。每次结果都追加在可复用前缀之后。 ### 前台结果 @@ -79,4 +99,5 @@ - **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。 - **等待中的一次性实例较晚才发现重复名称**(`TODO(subagent-dup-toolname)`):可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,仍需要一份预期名称注册表。 -- **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。 +- **随附 fork 工具无法选择子级 LLM 路由**:它们会继承父级的提供方与模型,使复制的对话前缀仍可供 KV Cache 复用。只有在路由变化仍能保留复用,或接口能公开一项有界的重算成本时,才重新启用这些字段。 +- **每个实例的非路由子 agent 策略固定**:其他 persona、工具过滤器或深度上限都需要另一个名称不同的工具。LLM 提供方/模型/推理强度选择要求静态启用或每 Session 偏好已启用,并要求 subagent 提供方声明 `agentOptions`;进程外提供方目前会拒绝启用它,而不是忽略它。 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 9ddab59996..708ca2335b 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -18,6 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./model-selection-settings": { + "types": "./lib/types/model-selection-settings.d.ts", + "default": "./lib/model-selection-settings.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -28,6 +32,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/model-selection-settings.js", "lib/types/**/*.d.ts" ], "license": "MIT", @@ -35,6 +40,9 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", @@ -50,8 +58,10 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index ef0e9941eb..ceb04cced8 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -10,14 +10,33 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' -import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent' +import { + assertSubagentMaxDepth, + parentAgentOptionsForDelegation, + settleRun, +} from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' import type { JobOutcome } from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { + hasConfiguredLlmSelection, + hasDelegationModelRequest, + preflightChildLlmRoute, + requestedAgentOptions, +} from './model-selection.ts' +import type { DelegationModelRequest } from './model-selection.ts' +import { registerListSubagentModels } from './list-models.ts' +import type {} from './model-selection-settings.ts' +import { + hasSubagentModelSelection, + recordSubagentModelSelection, +} from './model-selection-state.ts' export const name = 'tool-subagent' export const inject = ['tools', 'subagents', 'systemPrompt'] @@ -34,6 +53,14 @@ export interface Config { * a distinct name. */ toolName?: string + /** Let the model discover and select the child LLM route (default false). */ + enableModelSelection?: boolean + /** + * Sample the Host `subagent-model-selection` user setting for each new + * top-level session and inherit that decision in its child sessions. Mutually + * exclusive with `enableModelSelection`. + */ + modelSelectionSettings?: boolean /** * Expose `run_in_background` (default true). Disabled instances omit the * parameter and reject forced background calls. @@ -81,14 +108,22 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), + enableModelSelection: z.boolean().default(false), + modelSelectionSettings: z.boolean().default(false), enableRunInBackground: z.boolean().default(true), backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'), // Prevent Schemastery from materializing omitted agentOptions as `{}`. agentOptions: z.object({ provider: z.string(), model: z.string(), + reasoningEffort: z.string().min(1) as z>, maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER), - }).default(undefined as unknown as { provider: string; model: string; maxTokens: number }), + }).default(undefined as unknown as { + provider: string + model: string + reasoningEffort: ReturnType + maxTokens: number + }), persona: z.string(), // Preserve omission; Schemastery's `{ allow: [] }` default would deny every tool. toolFilter: z.object({ @@ -281,196 +316,348 @@ export function apply(ctx: Context, config: Config): void { if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) { throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter') } + if (config.enableModelSelection === true && config.modelSelectionSettings === true) { + throw new Error('tool-subagent: `enableModelSelection` and `modelSelectionSettings` are mutually exclusive') + } const backgroundEnabled = config.enableRunInBackground !== false const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' const toolName = config.toolName ?? 'subagent' - // Load order and HMR replacement can change provider availability while - // this fiber remains active. - let disposeTool: (() => void) | undefined - const mount = (provider: SubagentProvider): void => { - // A numeric cap the provider cannot enforce is a misconfiguration — fail at - // mount (the earliest point the provider's capabilities are known), not on - // the first delegation. - if (typeof config.maxDepth === 'number' && !provider.capabilities.depthLimit) { + + const modelSelectionCapable = config.enableModelSelection === true || config.modelSelectionSettings === true + + const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { + if (typeof config.maxDepth === 'number' && !subagentProvider.capabilities.depthLimit) { throw new Error( - `tool-subagent: provider "${provider.name}" cannot enforce maxDepth (no depthLimit capability) — ` + `tool-subagent: provider "${subagentProvider.name}" cannot enforce maxDepth (no depthLimit capability) — ` + 'set maxDepth: \'provider-managed\' to leave the recursion budget to the provider', ) } - const wording = providerWording(provider.inheritsParentContext) - if (continuable && provider.prepareContinuable === undefined) { + if (config.agentOptions !== undefined && !subagentProvider.capabilities.agentOptions) { throw new Error( - `tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``, + `tool-subagent: provider "${subagentProvider.name}" does not support child agentOptions`, + ) + } + if (modelSelectionCapable && !subagentProvider.capabilities.agentOptions) { + throw new Error( + `tool-subagent: provider "${subagentProvider.name}" does not support child model selection`, + ) + } + if (continuable && subagentProvider.prepareContinuable === undefined) { + throw new Error( + `tool-subagent: provider "${subagentProvider.name}" does not support \`backgroundMode: continuable\``, ) } - disposeTool = ctx.tools.register(defineTool({ - name: toolName, - description: wording.description + (backgroundEnabled - // 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 - ? ' This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.' - : ' This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.' - : ' This call waits for the subagent and returns its result.'), - parameters: { - description: { - type: 'string', - required: true, - description: 'A short (3-5 word) description of the delegated task, for display.', - }, - prompt: { - type: 'string', - required: true, - description: wording.promptDescription, - }, - ...backgroundEnabled ? { - run_in_background: { - type: 'boolean' as const, - description: continuable - ? 'Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it.' - : 'Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill.', - }, - } : {}, - }, - output: { - schema: { - oneOf: [ - { - type: 'object', - additionalProperties: false, - properties: { - kind: { type: 'string', required: true, const: 'background' }, - jobId: { type: 'string', required: true }, - }, - }, - { - type: 'object', - additionalProperties: false, - properties: { - kind: { type: 'string', required: true, const: 'continuable' }, - subagentId: { type: 'string', required: true }, - }, - }, - { - type: 'object', - additionalProperties: false, - properties: { - kind: { type: 'string', required: true, const: 'foreground' }, - runId: { type: 'string', required: true }, - output: { type: 'array', required: true, items: { type: 'json' } }, - }, - }, - ], - }, - render: (_args, value) => [{ - type: 'text', - text: value.kind === 'background' - ? `started background subagent job ${value.jobId}` - : value.kind === 'continuable' - ? `started subagent ${value.subagentId}` - : outputValueText(value.output), - }], - }, - // Children never mutate the parent session; the one parent-owned write - // (tasks.start) is a synchronous commutative insertion. - isConcurrencySafe: () => true, - async execute(args, exec) { - const parent = exec.agent - if (!parent) { - // Non-agent callers provide no parent for delegation ownership. - throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') - } - - const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined - const request = { - label: args.description, - prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[], - parent, - ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, - ...config.persona !== undefined ? { persona: config.persona } : {}, - ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, - ...maxDepth !== undefined ? { maxDepth } : {}, - } - - const runSpec = resolveDelegationRun(args, { backgroundEnabled, continuable }) - if (runSpec.runInBackground) { - if (continuable) { - // Resolves at inbox acceptance: the child owns its own turns from - // there, so this call neither waits for nor collects a result. - const started = await ctx.subagents.startContinuable({ - provider: config.provider, - label: args.description, - request, - signal: exec.signal, - }) - return { kind: 'continuable' as const, subagentId: started.childId } - } - const jobs = ctx.get('jobs') - if (jobs === undefined) { - throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') - } - // One-shot background child: job preflight finishes before the - // starter can spawn, and the task-owned signal covers startup. - const id = jobs.start({ - kind: 'subagent', - label: args.description, - owner: parent, - run: () => { - const controller = new AbortController() - const start = ctx.subagents.start(config.provider, { ...request, signal: controller.signal }) - return { - cancel: (reason?: string) => { - controller.abort(reason ?? 'background subagent task killed') - }, - done: settleStart(start, controller.signal), - // No readOutput: the child session owns intermediate detail. - } - }, - }) - return { kind: 'background' as const, jobId: id } - } - - const run: SubagentRun = await ctx.subagents.start(config.provider, { - ...request, - signal: exec.signal, - }) - return settleForegroundRun(run) - }, - })) } - // Register listeners before checking presence so no synchronous change is missed. - // TODO(subagent-dup-toolname): two waiting one-shot fibers configured with the - // same toolName collide when their provider appears, and the duplicate-name - // throw rolls back the provider registration. Continuable instances reserve - // their prompt-section name during apply() and fail earlier. Add an intent - // registry if the late one-shot collision occurs in a shipped composition. - ctx.on('subagent/provider-added', (provider) => { - if (provider.name === config.provider && disposeTool === undefined) mount(provider) + // Validate provider-owned config outside the optional LLM binding so an + // invalid provider always rejects its registration or this plugin's load. + ctx.on('subagent/provider-added', (subagentProvider) => { + if (subagentProvider.name === config.provider) assertSubagentProviderConfiguration(subagentProvider) }) - ctx.on('subagent/provider-removed', (name) => { - if (name !== config.provider || disposeTool === undefined) return - disposeTool() - disposeTool = undefined - }) - const present = ctx.subagents.getProvider(config.provider) - if (present !== undefined) { - mount(present) - } else { - // A backend fiber may activate later; a misspelled provider remains visible in this log. - ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) - } - if (backgroundEnabled && continuable) { - // The section follows provider availability without its own manual - // lifecycle: empty text is omitted from rendered prompts while the tool is - // absent, and the registration itself stays owned by this plugin fiber. - ctx.systemPrompt.section({ - name: `tool:${toolName}`, - order: SUBAGENT_SECTION_ORDER, - text: context => disposeTool === undefined || ctx.tools.get(toolName, context.scope) === undefined + const initialProvider = ctx.subagents.getProvider(config.provider) + if (initialProvider !== undefined) assertSubagentProviderConfiguration(initialProvider) + + const install = (runtimeCtx: Context, modelSelectionEnabled: boolean): void => { + if (modelSelectionEnabled) registerListSubagentModels(runtimeCtx) + // Load order and HMR replacement can change provider availability while + // this fiber remains active. + let mounted: { subagentProvider: SubagentProvider; disposeTool: () => void } | undefined + const mount = (subagentProvider: SubagentProvider): void => { + assertSubagentProviderConfiguration(subagentProvider) + const wording = providerWording(subagentProvider.inheritsParentContext) + const choiceDescription = !modelSelectionEnabled ? '' - : `Use ${toolName} in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set \`run_in_background: false\` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.`, + : ' Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model\'s default effort.' + + (subagentProvider.inheritsParentContext + ? ' Changing the route can prevent provider-side reuse of the inherited conversation prefix.' + : '') + const disposeTool = runtimeCtx.tools.register(defineTool({ + name: toolName, + description: wording.description + (backgroundEnabled + // 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 + ? ' This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.' + : ' This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.' + : ' This call waits for the subagent and returns its result.') + choiceDescription, + parameters: { + description: { + type: 'string', + required: true, + description: 'A short (3-5 word) description of the delegated task, for display.', + }, + prompt: { + type: 'string', + required: true, + description: wording.promptDescription, + }, + ...modelSelectionEnabled ? { + provider: { + type: 'string' as const, + description: 'LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route.', + }, + model: { + type: 'string' as const, + description: 'Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route.', + }, + reasoning_effort: { + type: 'string' as const, + description: 'Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model\'s default.', + }, + } : {}, + ...backgroundEnabled ? { + run_in_background: { + type: 'boolean' as const, + description: continuable + ? 'Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it.' + : 'Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill.', + }, + } : {}, + }, + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'background' }, + jobId: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'continuable' }, + subagentId: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + runId: { type: 'string', required: true }, + output: { type: 'array', required: true, items: { type: 'json' } }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background subagent job ${value.jobId}` + : value.kind === 'continuable' + ? `started subagent ${value.subagentId}` + : outputValueText(value.output), + }], + }, + // Children never mutate the parent session; the one parent-owned write + // (tasks.start) is a synchronous commutative insertion. + isConcurrencySafe: () => true, + async execute(args, exec) { + const parent = exec.agent + if (!parent) { + // Non-agent callers provide no parent for delegation ownership. + throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') + } + + const modelRequest = args as DelegationModelRequest + const parentOptions = parentAgentOptionsForDelegation(parent) + const childAgentOptions = requestedAgentOptions( + parentOptions, + config.agentOptions, + modelRequest, + modelSelectionEnabled, + ) + if (hasDelegationModelRequest(modelRequest) || hasConfiguredLlmSelection(config.agentOptions)) { + const llm = runtimeCtx.get('llm') + if (llm === undefined) { + throw new Error('cannot resolve the selected child LLM route because the `llm` service is unavailable') + } + await preflightChildLlmRoute(llm, parentOptions, childAgentOptions, exec.signal) + } + exec.signal.throwIfAborted() + const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined + const request = { + label: args.description, + prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[], + parent, + ...childAgentOptions !== undefined ? { agentOptions: childAgentOptions } : {}, + ...config.persona !== undefined ? { persona: config.persona } : {}, + ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, + ...maxDepth !== undefined ? { maxDepth } : {}, + } + + const runSpec = resolveDelegationRun(args, { backgroundEnabled, continuable }) + if (runSpec.runInBackground) { + if (continuable) { + // Resolves at inbox acceptance: the child owns its own turns from + // there, so this call neither waits for nor collects a result. + const started = await runtimeCtx.subagents.startContinuable({ + provider: config.provider, + label: args.description, + request, + signal: exec.signal, + }) + return { kind: 'continuable' as const, subagentId: started.childId } + } + const jobs = runtimeCtx.get('jobs') + if (jobs === undefined) { + throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') + } + // One-shot background child: job preflight finishes before the + // starter can spawn, and the task-owned signal covers startup. + const id = jobs.start({ + kind: 'subagent', + label: args.description, + owner: parent, + run: () => { + const controller = new AbortController() + const start = runtimeCtx.subagents.start(config.provider, { ...request, signal: controller.signal }) + return { + cancel: (reason?: string) => { + controller.abort(reason ?? 'background subagent task killed') + }, + done: settleStart(start, controller.signal), + // No readOutput: the child session owns intermediate detail. + } + }, + }) + return { kind: 'background' as const, jobId: id } + } + + const run: SubagentRun = await runtimeCtx.subagents.start(config.provider, { + ...request, + signal: exec.signal, + }) + return settleForegroundRun(run) + }, + })) + mounted = { subagentProvider, disposeTool } + } + + // Register listeners before checking presence so no synchronous change is missed. + // TODO(subagent-dup-toolname): two waiting one-shot fibers configured with the + // same toolName collide when their provider appears, and the duplicate-name + // throw rolls back the provider registration. Continuable instances reserve + // their prompt-section name during apply() and fail earlier. Add an intent + // registry if the late one-shot collision occurs in a shipped composition. + runtimeCtx.on('subagent/provider-added', (subagentProvider) => { + if (subagentProvider.name === config.provider && mounted === undefined) mount(subagentProvider) + }) + runtimeCtx.on('subagent/provider-removed', (name) => { + if (name !== config.provider || mounted === undefined) return + mounted.disposeTool() + mounted = undefined + }) + const present = runtimeCtx.subagents.getProvider(config.provider) + if (present !== undefined) { + mount(present) + } else { + // A backend fiber may activate later; a misspelled provider remains visible in this log. + runtimeCtx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) + } + if (backgroundEnabled && continuable) { + // The section follows provider availability without its own manual + // lifecycle: empty text is omitted from rendered prompts while the tool is + // absent, and the registration itself stays owned by this plugin fiber. + runtimeCtx.systemPrompt.section({ + name: `tool:${toolName}`, + order: SUBAGENT_SECTION_ORDER, + text: context => mounted === undefined || runtimeCtx.tools.get(toolName, context.scope) === undefined + ? '' + : `Use ${toolName} in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set \`run_in_background: false\` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.`, + }) + } + } + + if (config.modelSelectionSettings !== true) { + install(ctx, config.enableModelSelection === true) + return + } + + const settings = ctx.get('subagentModelSelection') + if (settings === undefined) { + throw new Error( + 'tool-subagent: `modelSelectionSettings` requires ' + + '@deepseek-ai/dsh-tool-subagent/model-selection-settings in the Host scope', + ) + } + const compositionScope = scopeOf(ctx) + if (compositionScope === undefined) { + throw new Error('tool-subagent: `modelSelectionSettings` requires an Agent or preset scope') + } + + const selectForAgent = (agent: NonNullable): boolean => { + let enabled = hasSubagentModelSelection(agent.session) + if (!enabled) { + const parentId = agent.session.header.origin === 'subagent' + ? agent.session.header.parentSession + : undefined + if (parentId !== undefined) { + const parent = ctx.get('agents')?.get(parentId) + enabled = parent !== undefined && hasSubagentModelSelection(parent.session) + } else if (agent.session.firstLiveSeq === 0) { + enabled = settings.currentEnabled() + } + } + if (enabled) recordSubagentModelSelection(agent.session) + return enabled + } + + const agent = ctx.agent + if (agent !== undefined) { + install(ctx, selectForAgent(agent)) + return + } + const agents = ctx.get('agents') + /* v8 ignore next -- Agent and preset scopes are minted only by the Agent registry. */ + if (agents === undefined) throw new Error('tool-subagent: scoped model-selection settings require the Agent registry') + const scopedInstalls = new WeakMap>() + const installing = new WeakSet() + const belongsToComposition = (candidate: Agent): boolean => + scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope) + const installScoped = (candidate: Agent): void => { + if (scopedInstalls.has(candidate) || installing.has(candidate)) return + // Reserve before the injected fiber runs: tool registration emits + // `tools/change` synchronously, which re-enters the reconciliation below. + installing.add(candidate) + const enabled = selectForAgent(candidate) + const fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { + install(runtimeCtx, enabled) + }) + installing.delete(candidate) + scopedInstalls.set(candidate, fiber) + } + const removeScoped = (candidate: Agent): void => { + const fiber = scopedInstalls.get(candidate) + if (fiber === undefined) return + scopedInstalls.delete(candidate) + /* v8 ignore next 3 -- Cordis Fiber disposal contains registration cleanup failures; this is the final diagnostic sink. */ + void fiber.dispose().catch((error: unknown) => { + ctx.logger.warn(`tool-subagent: failed to remove recomposed Agent "${candidate.id}" definitions: ${String(error)}`) }) } + const reconcileComposedAgents = (): void => { + // Every Agent and preset scope is minted by the Agent registry; the scope + // check above makes this same-process typed relationship authoritative. + for (const candidate of agents.list()) { + if (belongsToComposition(candidate)) installScoped(candidate) + else removeScoped(candidate) + } + } + // A shipped preset is mounted once in a standing scope. Its listener admits + // only descendant Agents and installs the sampled tool definition in each + // Agent's own scope, so a later settings change cannot mutate a live session. + ctx.on('agent/created', ({ agent: created }) => { + installScoped(created) + }) + ctx.on('agent/disposed', ({ agent: disposed }) => { removeScoped(disposed) }) + // Reparenting an Agent between standing presets changes its inherited tool + // set and emits `tools/change`; reconcile the Agent-owned override with the + // new ancestry. Other registry changes are idempotent no-ops here. + ctx.on('tools/change', reconcileComposedAgents) } diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 5b8facc900..84bd209caa 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -5,7 +5,8 @@ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { hasSubagentModelSelection } from './model-selection-state.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' @@ -14,11 +15,24 @@ export const name = 'tool-subagent-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -const install: InvariantInstaller = () => {} +/** Assert that a durable opt-in is represented by both model-facing definitions. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + ctx.on('agent/pre-step', async ({ agent }, next) => { + if (hasSubagentModelSelection(agent.session)) { + const schemas = ctx.tools.schemas(agent) + const selectable = schemas.some((schema) => { + const properties = (schema.parameters as { properties?: Record }).properties + return properties?.['provider'] !== undefined + && properties['model'] !== undefined + && properties['reasoning_effort'] !== undefined + }) + if (!selectable || !schemas.some(schema => schema.name === 'list_subagent_models')) { + fail('a subagent/model-selection-enabled session must expose route fields and list_subagent_models') + } + } + return next() + }, { global: true }) +}, { inject: ['tools'] }) /** * Register this package's invariant companion. diff --git a/packages/subagent/tool-subagent/src/list-models.ts b/packages/subagent/tool-subagent/src/list-models.ts new file mode 100644 index 0000000000..9e1ff5c24e --- /dev/null +++ b/packages/subagent/tool-subagent/src/list-models.ts @@ -0,0 +1,94 @@ +/** Model-facing discovery of LLM routes available to child Agents. */ + +import type { Context } from '@deepseek-ai/cordis' +import type LlmRuntime from '@deepseek-ai/dsh-llm' +import type { LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' + +interface ListSubagentModelsRequest { + readonly provider?: string + readonly model?: string +} + +/** Resolve one registered provider with a model-correctable diagnostic. */ +function registeredProvider(llm: LlmRuntime, providerId: string): LlmProviderInfo { + const providers = llm.listProviders() + const provider = providers.find(candidate => candidate.id === providerId) + if (provider !== undefined) return provider + const available = providers.map(candidate => candidate.id).join(', ') || '(none)' + throw new Error(`LLM provider "${providerId}" is not registered; available providers: ${available}`) +} + +/** Render one advertised or resolved model. */ +function modelLine(provider: string, model: { id: string; name: string; description?: string }): string { + return `${provider}/${model.id} — ${model.name}${model.description === undefined ? '' : `: ${model.description}`}` +} + +/** Read the requested provider, advertised models, or exact-model efforts. */ +async function listSubagentModels( + ctx: Context, + request: ListSubagentModelsRequest, + signal: AbortSignal, +): Promise { + const llm = ctx.get('llm') + if (llm === undefined) { + throw new Error('cannot discover child LLM routes because the `llm` service is unavailable') + } + if (request.model !== undefined && request.provider === undefined) { + throw new Error('`model` requires `provider`') + } + if (request.provider === undefined) { + const providers = llm.listProviders() + return providers.length === 0 + ? '(no LLM providers)' + : providers.map(provider => `${provider.id} — ${provider.name}`).join('\n') + } + if (request.provider.length === 0) throw new Error('`provider` must be non-empty') + const provider = registeredProvider(llm, request.provider) + if (request.model === undefined) { + const models = await llm.listModels(provider.id) + return models.length === 0 + ? `(no advertised models for ${provider.id})` + : models.map(model => modelLine(provider.id, model)).join('\n') + } + if (request.model.length === 0) throw new Error('`model` must be non-empty') + const model = await llm.resolveModelInfo(provider.id, request.model, signal) + const efforts = model.reasoning?.efforts.map(effort => ( + `${effort.id}${model.reasoning?.defaultEffort === effort.id ? ' (default)' : ''} — ${effort.name}` + + (effort.description === undefined ? '' : `: ${effort.description}`) + )).join('\n') || '(no advertised reasoning efforts)' + return `${modelLine(provider.id, model)}\nReasoning efforts:\n${efforts}` +} + +/** + * Register `list_subagent_models` for one owning delegation-tool instance. + * @param ctx - Context whose tool registry owns the fixed discovery definition. + */ +export function registerListSubagentModels(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'list_subagent_models', + description: + 'Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list ' + + 'registered providers, with `provider` to list its advertised models, or with `provider` and `model` ' + + 'to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may ' + + 'accept an unlisted model id. Use the returned ids with a delegation tool\'s `provider`, `model`, and ' + + '`reasoning_effort` fields.', + parameters: { + provider: { + type: 'string', + description: 'Registered LLM provider id. Omit to list providers.', + }, + model: { + type: 'string', + description: 'Exact model id to inspect. Requires provider; omit to list that provider\'s advertised models.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, result) => [{ type: 'text', text: result }], + }, + execute(args, exec) { + return listSubagentModels(ctx, args, exec.signal) + }, + })) +} diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts new file mode 100644 index 0000000000..113cd4c6f8 --- /dev/null +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -0,0 +1,70 @@ +/** Host-owned opt-in setting for model-selectable subagent delegation. */ + +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** User preference sampled when a new Agent receives its delegation tools. */ + subagentModelSelection: SubagentModelSelectionConfig + } +} + +/** User-settings section for model-selectable subagent delegation. */ +export const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = settingsNamespace('subagent-model-selection') + +/** Stored user preference; the shipped composition defaults it off. */ +export interface SubagentModelSelectionSettings { + /** Whether new Agents may expose child LLM route selection to the model. */ + enabled: boolean +} + +/** Schema served to settings clients for the opt-in preference. */ +export const SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA: z = z.object({ + enabled: z.boolean().default(false), +}) + +/** Optional deployment base for the preference. */ +export interface Config { + /** Initial value inherited when the user document does not override it. */ + enabled?: boolean +} + +/** Singleton settings owner read by delegation tools when an Agent is published. */ +export class SubagentModelSelectionConfig extends Service { + static Config: z = z.object({ + enabled: z.boolean().default(false), + }) + + private source: () => SubagentModelSelectionSettings + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'subagentModelSelection') + const entry: SubagentModelSelectionSettings = { enabled: config.enabled === true } + this.source = () => entry + installSettingsSection( + ctx, + SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, + SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA, + entry, + { + setSource: (source) => { this.source = source }, + // Consumers sample at Agent publication, so a settings update never + // rebuilds the tool definitions of an Agent that is already running. + onChange: () => {}, + }, + ) + } + + /** + * Read the preference for the next eligible Agent publication. + * @returns whether that Agent should receive model-selectable delegation. + */ + currentEnabled(): boolean { + return this.source().enabled + } +} + +export const name = 'subagent-model-selection-settings' +export default SubagentModelSelectionConfig diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts new file mode 100644 index 0000000000..35345ac115 --- /dev/null +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -0,0 +1,33 @@ +/** Durable per-session state for the user-controlled model-selection opt-in. */ + +import type { Session } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Records that this session's delegation tool exposes child provider, + * model, and reasoning-effort selection. Appended before the first model + * request; absence means the fixed-route definition. Log-only: it carries + * no `surfaceOp` and never enters model history. + */ + 'subagent/model-selection-enabled': Record + } +} + +/** + * Whether a session log records the enabled model-selection definition. + * @param session - session whose durable decision is read. + * @returns whether model-selectable delegation is enabled for the session. + */ +export function hasSubagentModelSelection(session: Session): boolean { + return session.events.some(event => event.type === 'subagent/model-selection-enabled') +} + +/** + * Append the enabled decision once, before its definition can reach a model request. + * @param session - session receiving the enabled decision. + */ +export function recordSubagentModelSelection(session: Session): void { + if (hasSubagentModelSelection(session)) return + session.append('subagent/model-selection-enabled', {}) +} diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts new file mode 100644 index 0000000000..6b89d92742 --- /dev/null +++ b/packages/subagent/tool-subagent/src/model-selection.ts @@ -0,0 +1,112 @@ +/** Child LLM route selection for the subagent tool. */ + +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { LlmRuntime } from '@deepseek-ai/dsh-llm' +import type { AgentOptions } from '@deepseek-ai/dsh-agent' + +/** Model-facing child LLM route fields. */ +export interface DelegationModelRequest { + readonly provider?: string + readonly model?: string + readonly reasoning_effort?: string +} + +/** + * Whether a call explicitly selects any child LLM value. + * @param request - Model-facing route fields from the tool call. + * @returns Whether at least one route or effort field is present. + */ +export function hasDelegationModelRequest(request: DelegationModelRequest): boolean { + return request.provider !== undefined + || request.model !== undefined + || request.reasoning_effort !== undefined +} + +/** Reject an empty model-facing route value at the tool JSON boundary. */ +function assertNonEmpty(value: string | undefined, field: keyof DelegationModelRequest): void { + if (value !== undefined && value.length === 0) { + throw new Error(`child LLM \`${field}\` must be non-empty`) + } +} + +/** + * Merge model-supplied selection fields over configured child defaults. + * Provider and model form one route and must be supplied together. Changing + * that route without an effort clears the configured route-owned effort. + * @param parentOptions - Current parent values that supply missing child values. + * @param configured - Tool-instance child defaults. + * @param request - Model-facing route override. + * @param enabled - Whether this tool instance permits model-facing selection. + * @returns Child Agent options, preserving omission when no layer contributes one. + */ +export function requestedAgentOptions( + parentOptions: AgentOptions, + configured: AgentOptions | undefined, + request: DelegationModelRequest, + enabled: boolean, +): AgentOptions | undefined { + if (!hasDelegationModelRequest(request)) return configured + if (!enabled) { + throw new Error('child model selection is disabled for this tool instance') + } + assertNonEmpty(request.provider, 'provider') + assertNonEmpty(request.model, 'model') + assertNonEmpty(request.reasoning_effort, 'reasoning_effort') + if ((request.provider === undefined) !== (request.model === undefined)) { + throw new Error('child LLM `provider` and `model` must be supplied together') + } + + const baselineProvider = configured?.provider ?? parentOptions.provider + const baselineModel = configured?.model ?? parentOptions.model + const routeChanged = request.provider !== undefined + && (request.provider !== baselineProvider || request.model !== baselineModel) + const { reasoningEffort: _configuredReasoningEffort, ...configuredWithoutReasoning } = configured ?? {} + return { + ...routeChanged && request.reasoning_effort === undefined ? configuredWithoutReasoning : configured, + ...request.provider === undefined ? {} : { provider: request.provider, model: request.model }, + ...request.reasoning_effort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(request.reasoning_effort) }, + } +} + +/** + * Whether configured Agent options require route validation before delegation. + * @param options - Tool-instance child defaults. + * @returns Whether configured provider, model, or effort values must be resolved. + */ +export function hasConfiguredLlmSelection(options: AgentOptions | undefined): boolean { + return options?.provider !== undefined + || options?.model !== undefined + || options?.reasoningEffort !== undefined +} + +/** + * Resolve an effective child route through its live adapter before the child is + * created. The LLM runtime owns provider lookup, exact-model metadata, effort + * validation, and adapter defaults. + * @param llm - Live LLM runtime. + * @param parentOptions - Current parent values whose compatible fields the child inherits. + * @param requested - Per-child options after request/config merging. + * @param signal - Tool-call cancellation signal. + */ +export async function preflightChildLlmRoute( + llm: LlmRuntime, + parentOptions: AgentOptions, + requested: AgentOptions | undefined, + signal: AbortSignal, +): Promise { + const provider = requested?.provider ?? parentOptions.provider + const model = requested?.model ?? parentOptions.model + if (provider === undefined || model === undefined) { + throw new Error('cannot select child LLM values without an effective provider and model') + } + const routeChanged = provider !== parentOptions.provider || model !== parentOptions.model + const reasoningEffort = requested?.reasoningEffort + ?? (routeChanged ? undefined : parentOptions.reasoningEffort) + await llm.resolveCallConfig({ + provider, + model, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + }, signal) +} diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts new file mode 100644 index 0000000000..36ac602c89 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -0,0 +1,57 @@ +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import * as mock from './scripted-provider.ts' +import * as tool from '../src/index.ts' + +/** Shared non-aborted tool signal for package-local integration tests. */ +export const testToolSignal = new AbortController().signal + +/** Build the minimal parent Agent owned by the package-local scripted provider. */ +export function fakeAgent(id = 'parent-1'): Agent { + const sessionId = SessionId(id) + return { id: sessionId, options: {}, session: Session.create(sessionId) } as unknown as Agent +} + +/** Mount the real tool and service stack around one scripted subagent provider. */ +export async function setup(toolConfig: tool.Config, mockConfig: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) + await ctx.plugin(tool, toolConfig) + return ctx +} + +let callCounter = 0 + +/** Execute the registered subagent tool through the real ToolRuntime pipeline. */ +export function callSubagent( + ctx: Context, + args: unknown, + over: { agent?: Agent | undefined; signal?: AbortSignal } = {}, +) { + // Distinguish "no override" (use a default agent) from an explicit + // `{ agent: undefined }` (test the no-agent path). Under + // exactOptionalPropertyTypes the key is omitted rather than set to undefined. + const agent = 'agent' in over ? over.agent : fakeAgent() + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`call-${++callCounter}`), + name: 'subagent', + arguments: args, + ...agent ? { agent } : {}, + ...over.signal ? { signal: over.signal } : {}, + }) +} + +/** Join text blocks from one rendered tool result. */ +export function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts new file mode 100644 index 0000000000..de3643fd97 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { + CallId, + LlmAdapter, + ReasoningEffortId, +} from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelInfo, + LlmResolvedModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import * as tool from '../src/index.ts' +import { testToolSignal, text } from './harness.ts' + +class CatalogAdapter extends LlmAdapter { + constructor(private readonly empty = false) { + super() + } + + override providerInfo(provider: string) { + return { id: provider, name: `${provider.toUpperCase()} API` } + } + + override listModels(provider: string): Promise { + if (this.empty) return Promise.resolve([]) + return Promise.resolve([ + { provider, id: 'fast', name: 'Fast', description: 'Focused work.' }, + { provider, id: 'plain', name: 'Plain' }, + ]) + } + + override resolveModel(provider: string, model: string): Promise { + if (model === 'plain') return Promise.resolve({ provider, id: model, name: 'Plain' }) + return Promise.resolve({ + provider, + id: model, + name: 'Fast', + description: 'Focused work.', + reasoning: { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'Quality first.' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + } + + stream(_options: GenerateOptions): AsyncIterable { + return (async function* () { yield { type: 'finish' as const, reason: { kind: 'stop' as const } } })() + } +} + +async function setupListTool() { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + const fiber = await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true }) + return { ctx, fiber } +} + +let counter = 0 + +function call(ctx: Context, args: unknown) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`list-models-${++counter}`), + name: 'list_subagent_models', + arguments: args, + }) +} + +describe('list_subagent_models', () => { + it('is omitted unless its delegation-tool instance owns discovery', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(tool, { provider: 'unused' }) + expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + }) + + it('stays registered without the optional LLM service and rejects discovery calls', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true }) + const result = await call(ctx, {}) + expect(result.isError).toBe(true) + expect(text(result)).toContain('`llm` service is unavailable') + }) + + it('rejects two discovery-owning instances in one tool scope', async () => { + const { ctx } = await setupListTool() + await expect(ctx.plugin(tool, { + provider: 'another-unused', + toolName: 'subagent_other', + enableModelSelection: true, + }).then(() => undefined)).rejects.toThrow('tool "list_subagent_models" is already registered') + }) + + it('lists registered providers and follows live registration changes', async () => { + const { ctx, fiber } = await setupListTool() + const empty = await call(ctx, {}) + expect(empty.isError).toBe(false) + expect(text(empty)).toBe('(no LLM providers)') + + const registration = ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const providers = await call(ctx, {}) + expect(providers.isError).toBe(false) + expect(text(providers)).toBe('alpha — ALPHA API') + + registration.replace(['beta']) + const changed = await call(ctx, {}) + expect(text(changed)).toBe('beta — BETA API') + + await fiber.dispose() + expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + }) + + it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'alpha' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('alpha/fast — Fast: Focused work.\nalpha/plain — Plain') + }) + + it('renders an empty advertised model list', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true)) + const result = await call(ctx, { provider: 'alpha' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('(no advertised models for alpha)') + }) + + it('inspects exact-model efforts, descriptions, and defaults', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'alpha', model: 'fast' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe( + 'alpha/fast — Fast: Focused work.\nReasoning efforts:\n' + + 'low — Low\nhigh (default) — High: Quality first.', + ) + }) + + it('renders exact models without reasoning metadata', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'alpha', model: 'plain' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('alpha/plain — Plain\nReasoning efforts:\n(no advertised reasoning efforts)') + }) + + it.each([ + { args: { model: 'fast' }, expected: '`model` requires `provider`' }, + { args: { provider: '' }, expected: '`provider` must be non-empty' }, + { args: { provider: 'missing' }, expected: 'available providers: (none)' }, + ])('rejects incomplete or unavailable provider requests', async ({ args, expected }) => { + const { ctx } = await setupListTool() + const result = await call(ctx, args) + expect(result.isError).toBe(true) + expect(text(result)).toContain(expected) + }) + + it('rejects an empty exact model after resolving the provider', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'alpha', model: '' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('`model` must be non-empty') + }) + + it('reports registered alternatives for an unavailable provider', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'missing' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('available providers: alpha') + }) +}) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts new file mode 100644 index 0000000000..4f95db088f --- /dev/null +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -0,0 +1,248 @@ +/** Default-off settings and per-session model-selection decisions. */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import { SettingsProvider } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import * as tool from '../src/index.ts' +import * as ToolInvariant from '../src/invariant.ts' +import SubagentModelSelectionConfig, { + SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, +} from '../src/model-selection-settings.ts' +import { hasSubagentModelSelection } from '../src/model-selection-state.ts' + +/** Writable in-memory settings provider for the package integration. */ +class MemorySettings extends SettingsProvider { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +/** Read whether one Agent's delegation definition contains route fields. */ +function selectable(ctx: Context, agent: Awaited>['agent']): boolean { + const schema = ctx.tools.schemas(agent).find(candidate => candidate.name === 'subagent') + const properties = (schema?.parameters as { properties?: Record } | undefined)?.properties + return properties?.['provider'] !== undefined + && properties['model'] !== undefined + && properties['reasoning_effort'] !== undefined + && ctx.tools.schemas(agent).some(candidate => candidate.name === 'list_subagent_models') +} + +/** Mount the real settings, Agent, provider, and tool services. */ +async function boot(): Promise { + const ctx = new Context() + await ctx.plugin(MemorySettings) + await ctx.plugin(SubagentModelSelectionConfig) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + return ctx +} + +/** Create one Agent whose setup mounts the settings-controlled tool preset row. */ +async function createAgent(ctx: Context, id: string, options: { + meta?: { parentSession: SessionId; origin: 'subagent' } + seed?: readonly SessionEvent[] +} = {}) { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + ...options, + setup: async (agentCtx) => { + await agentCtx.plugin(tool, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + }, + }) + return handle.agent +} + +describe('SubagentModelSelectionConfig', () => { + it('uses the composed default without a settings provider', async () => { + const ctx = new Context() + await ctx.plugin(SubagentModelSelectionConfig, { enabled: true }) + + expect(ctx.subagentModelSelection.currentEnabled()).toBe(true) + await ctx.fiber.dispose() + }) + + it('defaults off and follows the validated user layer', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings) + await ctx.plugin(SubagentModelSelectionConfig) + + expect(ctx.subagentModelSelection.currentEnabled()).toBe(false) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + expect(ctx.subagentModelSelection.currentEnabled()).toBe(true) + await ctx.fiber.dispose() + }) + + it('samples each new root session without changing existing Agents', async () => { + const ctx = await boot() + const disabled = await createAgent(ctx, 'disabled') + expect(selectable(ctx, disabled)).toBe(false) + expect(hasSubagentModelSelection(disabled.session)).toBe(false) + + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const enabled = await createAgent(ctx, 'enabled') + expect(hasSubagentModelSelection(enabled.session)).toBe(true) + expect(selectable(ctx, enabled)).toBe(true) + expect(selectable(ctx, disabled)).toBe(false) + + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + const disabledAgain = await createAgent(ctx, 'disabled-again') + expect(selectable(ctx, disabledAgain)).toBe(false) + expect(selectable(ctx, enabled)).toBe(true) + await ctx.fiber.dispose() + }) + + it('installs per-Agent definitions for a shared preset scope', async () => { + const ctx = await boot() + const preset = createScope(ctx, { preset: 'standard' }) + const other = createScope(ctx, { preset: 'minimal' }) + await preset.ctx.plugin(tool, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + + let enabledBinding: ReturnType | undefined + const createComposed = async (id: string) => ctx.agents.create({ + sessionId: SessionId(id), + setup: (agentCtx) => { + const binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!) + if (id === 'preset-enabled') enabledBinding = binding + }, + }) + + const disabled = await createComposed('preset-disabled') + expect(selectable(ctx, disabled.agent)).toBe(false) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const enabled = await createComposed('preset-enabled') + expect(selectable(ctx, enabled.agent)).toBe(true) + expect(selectable(ctx, disabled.agent)).toBe(false) + + enabledBinding!.rebind(scopeOf(other.ctx)!) + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(false) }) + enabledBinding!.rebind(scopeOf(preset.ctx)!) + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(true) }) + + await enabled.dispose() + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await disabled.dispose() + await ctx.fiber.dispose() + }) + + it('inherits the parent decision and preserves seeded decisions across composition', async () => { + const ctx = await boot() + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const parent = await createAgent(ctx, 'parent') + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + const child = await createAgent(ctx, 'child', { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + expect(selectable(ctx, child)).toBe(true) + expect(hasSubagentModelSelection(child.session)).toBe(true) + + const enabledSeed = Session.create(SessionId('enabled-seed')) + enabledSeed.append('subagent/model-selection-enabled', {}) + const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.events }) + expect(selectable(ctx, resumedEnabled)).toBe(true) + + const oldSeed = Session.create(SessionId('old-seed'), []) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) + expect(selectable(ctx, resumedDisabled)).toBe(false) + expect(hasSubagentModelSelection(resumedDisabled.session)).toBe(false) + await ctx.fiber.dispose() + }) + + it('rejects ambiguous static and settings-controlled configuration', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SubagentRuntime) + expect(() => { + tool.apply(ctx, { + provider: 'missing', + enableModelSelection: true, + modelSelectionSettings: true, + }) + }).toThrow('mutually exclusive') + await ctx.fiber.dispose() + }) + + it('requires both the Host setting owner and a composition scope', async () => { + const withoutSettings = new Context() + await mountAgentLoopTestDependencies(withoutSettings) + await withoutSettings.plugin(SubagentRuntime) + expect(() => { + tool.apply(withoutSettings, { + provider: 'missing', + modelSelectionSettings: true, + maxDepth: 'provider-managed', + }) + }).toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings') + await withoutSettings.fiber.dispose() + + const withoutAgent = await boot() + expect(() => { + tool.apply(withoutAgent, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + }).toThrow('requires an Agent or preset scope') + await withoutAgent.fiber.dispose() + }) + + it('checks the durable decision against the published tool definitions', async () => { + const ctx = await boot() + await ctx.plugin(InvariantRegistry, { enabled: true }) + await ctx.plugin(ToolInvariant) + const disabled = await createAgent(ctx, 'invariant-disabled') + const next = () => Promise.resolve({ kind: 'enter' as const, messages: [] }) + const payload = { + agent: disabled, + messages: [], + turn: 1, + step: 1, + signal: new AbortController().signal, + } + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)).resolves.toEqual({ + kind: 'enter', messages: [], + }) + + disabled.session.append('subagent/model-selection-enabled', {}) + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) + .rejects.toThrow('must expose route fields and list_subagent_models') + + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const enabled = await createAgent(ctx, 'invariant-enabled') + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) + .resolves.toEqual({ kind: 'enter', messages: [] }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts new file mode 100644 index 0000000000..008ba66da4 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as mock from './scripted-provider.ts' +import * as tool from '../src/index.ts' +import { callSubagent, setup, text } from './harness.ts' + +const REASONING = { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), +} as const + +function parentWithRoute( + options: Agent['options'] = { + provider: 'alpha', + model: 'parent-model', + reasoningEffort: ReasoningEffortId('high'), + }, +): Agent { + const id = SessionId('parent-with-route') + return { id, options, session: Session.create(id) } as unknown as Agent +} + +describe('dsh-tool-subagent model selection', () => { + it('exposes static route fields and discovery when selection is enabled', async () => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + const props = (schema.parameters as { properties?: Record }).properties ?? {} + expect(Object.keys(props).sort()).toEqual([ + 'description', + 'model', + 'prompt', + 'provider', + 'reasoning_effort', + 'run_in_background', + ]) + expect(schema.description).toContain('list_subagent_models') + expect(ctx.tools.get('list_subagent_models')).toBeDefined() + expect(schema.description).not.toContain('alpha') + + const registration = ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) + const definition = ctx.tools.get('subagent') + registration.replace(['beta']) + expect(ctx.tools.get('subagent')).toBe(definition) + expect(definition?.description).not.toContain('beta') + }) + + it('hides and rejects route fields when selection is disabled', async () => { + const ctx = await setup({ provider: 'mock' }) + const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + const props = (schema.parameters as { properties?: Record }).properties ?? {} + expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background']) + expect(schema.description).not.toContain('list_subagent_models') + expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + + const result = await callSubagent(ctx, { + description: 'forced route', + prompt: 'do it', + provider: 'alpha', + model: 'fast-model', + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('child model selection is disabled for this tool instance') + }) + + it('rejects enabled model selection when the provider cannot apply Agent options', async () => { + await expect(setup( + { provider: 'mock', enableModelSelection: true, maxDepth: 'provider-managed' }, + { capabilities: { agentOptions: false } }, + )).rejects.toThrow('provider "mock" does not support child model selection') + }) + + it('selects an unlisted complete route and clears a configured effort when the route changes', async () => { + const requests: SubagentStartRequest[] = [] + const ctx = await setup({ + provider: 'mock', + enableModelSelection: true, + agentOptions: { + provider: 'alpha', + model: 'configured-model', + reasoningEffort: ReasoningEffortId('high'), + maxTokens: 321, + }, + }, { onStart: (request) => { requests.push(request) } }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + + const selected = await callSubagent(ctx, { + description: 'route work', + prompt: 'do it', + provider: 'alpha', + model: 'unlisted-model', + }) + expect(selected.isError).toBe(false) + expect(requests[0]?.agentOptions).toEqual({ + provider: 'alpha', + model: 'unlisted-model', + maxTokens: 321, + }) + + const effort = await callSubagent(ctx, { + description: 'same route effort', + prompt: 'do it', + provider: 'alpha', + model: 'configured-model', + reasoning_effort: 'low', + }) + expect(effort.isError).toBe(false) + expect(requests[1]?.agentOptions).toEqual({ + provider: 'alpha', + model: 'configured-model', + reasoningEffort: 'low', + maxTokens: 321, + }) + }) + + it('accepts an effort-only override for the effective configured or parent route', async () => { + const requests: SubagentStartRequest[] = [] + const ctx = await setup({ + provider: 'mock', + enableModelSelection: true, + agentOptions: { provider: 'alpha' }, + }, { onStart: (request) => { requests.push(request) } }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + + const result = await callSubagent(ctx, { + description: 'effort work', + prompt: 'do it', + reasoning_effort: 'low', + }, { agent: parentWithRoute() }) + expect(result.isError).toBe(false) + expect(requests[0]?.agentOptions).toEqual({ provider: 'alpha', reasoningEffort: 'low' }) + + const inherited = await setup({ provider: 'mock', enableModelSelection: true }) + inherited.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const inheritedResult = await callSubagent(inherited, { + description: 'parent effort work', + prompt: 'do it', + reasoning_effort: 'low', + }, { agent: parentWithRoute() }) + expect(inheritedResult.isError).toBe(false) + }) + + it('inherits a parent effort only when an explicit route stays unchanged', async () => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const result = await callSubagent(ctx, { + description: 'same route work', + prompt: 'do it', + provider: 'alpha', + model: 'parent-model', + }, { agent: parentWithRoute() }) + expect(result.isError).toBe(false) + }) + + it('compares explicit routes with the latest logged parent selection', async () => { + const requests: SubagentStartRequest[] = [] + const ctx = await setup({ + provider: 'mock', + enableModelSelection: true, + agentOptions: { reasoningEffort: ReasoningEffortId('high') }, + }, { onStart: (request) => { requests.push(request) } }) + ctx.llm.registerAdapter(['current-provider'], new MockAdapter([], REASONING)) + const parent = parentWithRoute({ provider: 'created-provider', model: 'created-model' }) + parent.session.append('request/header', { + header: { config: { provider: 'current-provider', model: 'current-model' } }, + reason: 'initial', + }) + + const result = await callSubagent(ctx, { + description: 'same current route', + prompt: 'do it', + provider: 'current-provider', + model: 'current-model', + }, { agent: parent }) + + expect(result.isError).toBe(false) + expect(requests[0]?.agentOptions).toEqual({ + provider: 'current-provider', + model: 'current-model', + reasoningEffort: 'high', + }) + }) + + it('rejects an effort without any effective route', async () => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const result = await callSubagent(ctx, { + description: 'missing route', + prompt: 'do it', + reasoning_effort: 'low', + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('without an effective provider and model') + }) + + it.each([ + { provider: 'alpha' }, + { model: 'fast-model' }, + ])('rejects a partial model-facing route before child creation', async (route) => { + let starts = 0 + const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const result = await callSubagent(ctx, { description: 'partial route', prompt: 'do it', ...route }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('`provider` and `model` must be supplied together') + expect(starts).toBe(0) + }) + + it.each([ + { provider: '', model: 'fast-model', expected: '`provider` must be non-empty' }, + { provider: 'alpha', model: '', expected: '`model` must be non-empty' }, + { reasoning_effort: '', expected: '`reasoning_effort` must be non-empty' }, + ])('rejects empty model-facing values', async ({ expected, ...selection }) => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const result = await callSubagent(ctx, { description: 'empty route', prompt: 'do it', ...selection }) + expect(result.isError).toBe(true) + expect(text(result)).toContain(expected) + }) + + it('uses the LLM runtime for provider and reasoning-effort validation before child creation', async () => { + let starts = 0 + const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + + const unsupported = await callSubagent(ctx, { + description: 'bad effort', + prompt: 'do it', + provider: 'alpha', + model: 'fast-model', + reasoning_effort: 'max', + }) + expect(unsupported.isError).toBe(true) + expect(text(unsupported)).toContain('does not support reasoning effort "max"') + + const missing = await callSubagent(ctx, { + description: 'bad provider', + prompt: 'do it', + provider: 'missing', + model: 'fast-model', + }) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain('no adapter registered for provider "missing"') + expect(starts).toBe(0) + }) + + it('validates a configured effort before child creation', async () => { + let starts = 0 + const ctx = await setup({ + provider: 'mock', + agentOptions: { + provider: 'alpha', + model: 'parent-model', + reasoningEffort: ReasoningEffortId('high'), + }, + }, { onStart: () => { starts += 1 } }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], { + efforts: [{ id: ReasoningEffortId('low'), name: 'Low' }], + defaultEffort: ReasoningEffortId('low'), + })) + + const result = await callSubagent( + ctx, + { description: 'same route', prompt: 'do it' }, + { agent: parentWithRoute() }, + ) + expect(result.isError).toBe(true) + expect(text(result)).toContain('does not support reasoning effort "high"') + expect(starts).toBe(0) + }) + + it('validates a configured route before child creation', async () => { + let starts = 0 + const ctx = await setup({ + provider: 'mock', + agentOptions: { provider: 'missing', model: 'configured-model' }, + }, { onStart: () => { starts += 1 } }) + + const result = await callSubagent( + ctx, + { description: 'configured route', prompt: 'do it' }, + { agent: parentWithRoute() }, + ) + + expect(result.isError).toBe(true) + expect(text(result)).toContain('no adapter registered for provider "missing"') + expect(starts).toBe(0) + }) + + it('rejects selected routes or configured efforts when the LLM service is absent', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await mock.mountScriptedProvider(ctx, { name: 'mock' }) + await ctx.plugin(tool, { + provider: 'mock', + enableModelSelection: true, + agentOptions: { + provider: 'alpha', + model: 'fast-model', + reasoningEffort: ReasoningEffortId('high'), + }, + }) + + const configured = await callSubagent(ctx, { description: 'configured effort', prompt: 'do it' }) + expect(configured.isError).toBe(true) + expect(text(configured)).toContain('`llm` service is unavailable') + + const selected = await callSubagent(ctx, { + description: 'selected route', + prompt: 'do it', + provider: 'alpha', + model: 'other-model', + }) + expect(selected.isError).toBe(true) + expect(text(selected)).toContain('`llm` service is unavailable') + }) + + it('keeps pure inherited routing usable without an LLM service lookup', async () => { + let starts = 0 + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await mock.mountScriptedProvider(ctx, { name: 'mock', onStart: () => { starts += 1 } }) + await ctx.plugin(tool, { provider: 'mock' }) + + const result = await callSubagent(ctx, { description: 'inherit route', prompt: 'do it' }) + expect(result.isError).toBe(false) + expect(starts).toBe(1) + }) + + it('warns that changing a fork route can lose inherited-prefix reuse', async () => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { inheritsParentContext: true }) + const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + expect(schema.description).toContain('inherits this conversation') + expect(schema.description).toContain('can prevent provider-side reuse of the inherited conversation prefix') + }) + + it('propagates an exact-route resolver failure before child creation', async () => { + let starts = 0 + const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const adapter = new MockAdapter([]) + vi.spyOn(adapter, 'resolveModel').mockRejectedValue(new Error('selected route unavailable')) + ctx.llm.registerAdapter(['alpha'], adapter) + + const result = await callSubagent(ctx, { + description: 'route work', + prompt: 'do it', + provider: 'alpha', + model: 'fast-model', + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('selected route unavailable') + expect(starts).toBe(0) + }) +}) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index c0da403cd4..a946c6f6fe 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -13,6 +13,7 @@ import type { } from '@deepseek-ai/dsh-subagent' const DEFAULT_CAPABILITIES: SubagentCapabilities = { + agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 1ee5e40228..334b721461 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import { CallId } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' @@ -20,9 +20,8 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' -import { SessionId } from '@deepseek-ai/dsh-session' - -const testToolSignal = new AbortController().signal +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { callSubagent, fakeAgent, setup, testToolSignal, text } from './harness.ts' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real @@ -32,40 +31,6 @@ const testToolSignal = new AbortController().signal * shipping code path. */ -/** A minimal parent Agent passed through to the provider request. */ -function fakeAgent(id = 'parent-1'): Agent { - return { id: SessionId(id) } as unknown as Agent -} - -async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) - await ctx.plugin(tool, toolConfig) - return ctx -} - -let callCounter = 0 -function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undefined; signal?: AbortSignal } = {}) { - // Distinguish "no override" (use a default agent) from an explicit - // `{ agent: undefined }` (test the no-agent path). Under - // exactOptionalPropertyTypes the key is omitted rather than set to undefined. - const agent = 'agent' in over ? over.agent : fakeAgent() - return ctx.tools.execute({ - signal: testToolSignal, - callId: CallId(`call-${++callCounter}`), - name: 'subagent', - arguments: args, - ...agent ? { agent } : {}, - ...over.signal ? { signal: over.signal } : {}, - }) -} - -function text(result: { content: { type: string; text?: string }[] }): string { - return result.content.filter(b => b.type === 'text').map(b => b.text).join('') -} describe('dsh-tool-subagent', () => { it('rejects continuable background policy when the provider cannot prepare continuable children', async () => { @@ -83,6 +48,13 @@ describe('dsh-tool-subagent', () => { ) }) + it('rejects configured child agent options at mount when the provider cannot apply them', async () => { + await expect(setup( + { provider: 'mock', maxDepth: 'provider-managed', agentOptions: { model: 'configured-model' } }, + { capabilities: { agentOptions: false } }, + )).rejects.toThrow('does not support child agentOptions') + }) + it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => { const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) const result = await callSubagent(ctx, { @@ -100,20 +72,14 @@ describe('dsh-tool-subagent', () => { expect(text(result)).toBe('child says hi') }) - it('exposes description + prompt + run_in_background to the model (no provider/type parameter)', async () => { - const ctx = await setup({ provider: 'mock' }) - const schema = ctx.tools.schemas().find(s => s.name === 'subagent') - expect(schema).toBeDefined() - const props = (schema!.parameters as { properties?: Record }).properties ?? {} - expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background']) - expect(schema!.description).toContain('job_output') - }) - it('omits run_in_background entirely when the instance disables it (schema and capability never disagree)', async () => { const ctx = await setup({ provider: 'mock', enableRunInBackground: false }) const schema = ctx.tools.schemas().find(s => s.name === 'subagent') const props = (schema!.parameters as { properties?: Record }).properties ?? {} - expect(Object.keys(props).sort()).toEqual(['description', 'prompt']) + expect(Object.keys(props).sort()).toEqual([ + 'description', + 'prompt', + ]) expect(schema!.description).not.toContain('job_output') }) @@ -121,7 +87,13 @@ describe('dsh-tool-subagent', () => { // Schema omission is advertising, not enforcement: the arg validator // allows undeclared keys, so the opt-out must also hold in execute(). const ctx = await setup({ provider: 'mock', enableRunInBackground: false }) - const parent = { id: SessionId('sess-off'), inject: () => {}, options: {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent + const parentId = SessionId('sess-off') + const parent = { + id: parentId, + inject: () => {}, + options: {}, + session: Session.create(parentId), + } as unknown as Agent const forced = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent }) expect(forced.isError).toBe(true) @@ -232,7 +204,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'weird', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('weird-child'), @@ -253,12 +225,13 @@ describe('dsh-tool-subagent', () => { // the request lets us assert the agentOptions reached it. let seen: { agentOptions?: { model?: string } } | undefined const ctx = new Context() + await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { seen = request @@ -270,10 +243,15 @@ describe('dsh-tool-subagent', () => { } }, }) - await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model' }, maxDepth: 'provider-managed' }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) + await ctx.plugin(tool, { + provider: 'capture', + agentOptions: { provider: 'alpha', model: 'child-model' }, + maxDepth: 'provider-managed', + }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(seen?.agentOptions).toEqual({ model: 'child-model' }) + expect(seen?.agentOptions).toEqual({ provider: 'alpha', model: 'child-model' }) }) it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { @@ -288,7 +266,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'bare', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { seen = request @@ -378,7 +356,7 @@ describe('dsh-tool-subagent', () => { // the provider survives. ctx.subagents.registerProvider({ name: 'continuable', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('lifecycle test does not start a child') }, prepareContinuable: async () => ({}), @@ -429,10 +407,14 @@ describe('dsh-tool-subagent', () => { }) it('derives inherited-context wording from a seeded-conversation provider', async () => { - const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true }) + const ctx = await setup({ + provider: 'mock', + toolName: 'subagent', + }, { inheritsParentContext: true }) const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! expect(schema.description).toContain('inherits this conversation') expect(schema.description).not.toContain('does not see this conversation') + expect(schema.description).not.toContain('can prevent provider-side reuse of the inherited conversation prefix') const props = (schema.parameters as { properties: Record }).properties expect(props['prompt']!.description).toContain('completed turns') }) @@ -447,7 +429,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), @@ -470,7 +452,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), @@ -494,7 +476,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), @@ -522,7 +504,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), @@ -549,7 +531,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { if (request.signal.aborted) throw new Error('start aborted') @@ -588,7 +570,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { if (request.signal.aborted) sawAborted() @@ -652,7 +634,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture2', - capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: async (request) => { seen = request @@ -709,7 +691,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture3', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, inheritsParentContext: false, start: async (request) => { seen = request @@ -739,7 +721,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture4', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { seen = request @@ -764,7 +746,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'p', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, inheritsParentContext: false, start: () => { throw new Error('unreachable') }, }) @@ -783,7 +765,7 @@ describe('dsh-tool-subagent background mode', () => { ctx: scopeFiber.ctx, inject, options: {}, - session: { id, header: { version: 0, id, createdAt: 0 } }, + session: Session.create(id), } as unknown as Agent ctx.agents.register(agent) return agent @@ -803,7 +785,7 @@ describe('dsh-tool-subagent background mode', () => { let prepareCalls = 0 ctx.subagents.registerProvider({ name: 'resumable', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async request => ({ id: SessionId('one-shot-child'), @@ -839,7 +821,7 @@ describe('dsh-tool-subagent background mode', () => { }) it('returns a job id immediately and the answer is collected through job_output', async () => { - const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' }) + const ctx = await backgroundSetup({ provider: 'mock' }, { reply: 'background answer' }) const parent = ownerAgent(ctx, 'sess-parent') const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent }) @@ -919,12 +901,41 @@ describe('dsh-tool-subagent background mode', () => { expect(text(result)).toBe('Error: tool call aborted before dispatch') }) + it('skips background startup when cancellation wins asynchronous route preflight', async () => { + const ctx = await backgroundSetup({ provider: 'mock', enableModelSelection: true }) + const parent = ownerAgent(ctx, 'sess-parent') + const adapter = new MockAdapter([]) + let releasePreflight!: () => void + const preflightGate = new Promise((resolve) => { releasePreflight = resolve }) + const resolveModel = vi.spyOn(adapter, 'resolveModel').mockImplementation(async (provider, model) => { + await preflightGate + return { provider, id: model, name: model } + }) + ctx.llm.registerAdapter(['alpha'], adapter) + const controller = new AbortController() + + const resultPromise = callSubagent(ctx, { + description: 'cancelled selection', + prompt: 'do it', + provider: 'alpha', + model: 'selected-model', + run_in_background: true, + }, { agent: parent, signal: controller.signal }) + await vi.waitFor(() => { expect(resolveModel).toHaveBeenCalledOnce() }) + controller.abort() + releasePreflight() + const result = await resultPromise + + expect(result.isError).toBe(true) + expect(ctx.jobs.list(parent)).toEqual([]) + }) + it('settles an asynchronous provider-start failure as a failed task', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) const parent = ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'broken-start', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('setup failed') }, }) @@ -953,7 +964,7 @@ describe('dsh-tool-subagent background mode', () => { const parent = ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'pending-start', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: request => new Promise((_resolve, reject) => { request.signal.addEventListener('abort', () => { reject(new Error('startup aborted')) }, { once: true }) @@ -990,7 +1001,7 @@ describe('dsh-tool-subagent background mode', () => { const parent = ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'broken-start-rollback', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: request => new Promise((_resolve, reject) => { request.signal.addEventListener('abort', () => { @@ -1035,7 +1046,7 @@ describe('dsh-tool-subagent background mode', () => { let starts = 0 ctx.subagents.registerProvider({ name: 'hanging', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void @@ -1182,7 +1193,7 @@ describe('dsh-tool-subagent continuable background mode', () => { let survivingChildId: ReturnType | undefined ctx.subagents.registerProvider({ name: 'gated', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: false, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: async () => { throw new Error('continuable policy must not start a one-shot child') }, prepareContinuable: async (request) => { @@ -1250,14 +1261,14 @@ describe('background preflight failure (no orphaned child, by construction)', () ctx: scopeFiber.ctx, inject: () => {}, options: {}, - session: { id, header: { version: 0, id, createdAt: 0 } }, + session: Session.create(id), } as unknown as Agent ctx.agents.register(parent) let starts = 0 ctx.subagents.registerProvider({ name: 'probe', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { starts += 1 @@ -1295,7 +1306,7 @@ describe('depth budget configuration', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: false, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: async (request) => { requests.push(request) @@ -1333,7 +1344,7 @@ describe('depth budget configuration', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'no-depth', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('unreachable') }, }) @@ -1349,7 +1360,7 @@ describe('depth budget configuration', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'external', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { requests.push(request) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 8ee77dfb3b..57ee1dc9d6 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -20,6 +20,15 @@ { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../settings/settings" + }, { "path": "../../llm/llm" }, diff --git a/packages/subagent/tool-subagent/tsdown.config.ts b/packages/subagent/tool-subagent/tsdown.config.ts new file mode 100644 index 0000000000..d91febcc25 --- /dev/null +++ b/packages/subagent/tool-subagent/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown' + +const entry = (path: string) => ({ + entry: [path], + outDir: 'lib', + format: ['esm'] as const, + platform: 'node' as const, + target: 'es2024' as const, + fixedExtension: false, + dts: false, + clean: false, +}) + +/** Build self-contained Loader entries so the package needs no private chunks. */ +export default defineConfig([ + entry('lib/types/index.js'), + entry('lib/types/model-selection-settings.js'), + entry('lib/types/invariant.js'), +]) diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts index 7c6f02e36c..0ccbbd16c6 100644 --- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -56,6 +56,7 @@ class StubProvider implements SubagentProvider { constructor(options?: { outputSchema?: boolean; inheritsParentContext?: boolean }) { this.capabilities = { + agentOptions: true, outputSchema: options?.outputSchema ?? true, depthLimit: true, toolFilter: true, diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 938f9a5502..f89083faba 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -423,7 +423,7 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spawn', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('the parked-script fixture must not start a child')), }) diff --git a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts index 31998a22cf..cf7439e8eb 100644 --- a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts @@ -30,7 +30,7 @@ await ctx.plugin(SubagentRuntime) let selectedStarts = 0 ctx.subagents.registerProvider({ name: 'built-selected', - capabilities: { outputSchema: true, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, async start() { selectedStarts += 1 diff --git a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts index 8d33e373c7..77b87b59f8 100644 --- a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts @@ -21,7 +21,7 @@ it('runs the default config through the source worker', async () => { const subagents = await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'spawn', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('source-worker compat script must not start a child')), } diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index 5130071362..935dbae3fb 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -55,7 +55,13 @@ interface ControlledRun { * the request signal fires, like the real in-process backends. */ class StubProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: false } + readonly capabilities: SubagentCapabilities = { + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: false, + } readonly inheritsParentContext = false readonly runs: ControlledRun[] = [] @@ -459,7 +465,7 @@ describe('dsh-workflow-worker-thread', () => { await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'rejecting', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('reject-child'), @@ -518,7 +524,7 @@ describe('dsh-workflow-worker-thread', () => { await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'bad-dispose', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('bad-dispose-child'), @@ -540,7 +546,7 @@ describe('dsh-workflow-worker-thread', () => { await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'coercion-trap-dispose', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('trap-child'), @@ -891,7 +897,7 @@ describe('dsh-workflow-worker-thread', () => { const aborted: string[] = [] const provider: SubagentProvider = { name: 'signal-only', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async (request) => { let settle!: (result: SubagentResult) => void @@ -1189,7 +1195,7 @@ describe('dsh-workflow-worker-thread', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) const provider: SubagentProvider = { name: 'late-ready', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: (request) => { requested.resolve(request) @@ -1250,7 +1256,7 @@ describe('dsh-workflow-worker-thread', () => { const signalAborts: unknown[] = [] const provider: SubagentProvider = { name: 'doomed', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async (request) => { request.signal.addEventListener('abort', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6b3e12d45..f6878a775b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1808,6 +1808,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../subagent/tool-subagent '@deepseek-ai/dsh-web-frontend': specifier: workspace:^ version: link:../../../apps/web @@ -8563,6 +8566,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -8572,6 +8578,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 35e6eb1049..cd9620e0fa 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -153,6 +153,9 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], // The shipped preset compositions travel inside the roster package. '@deepseek-ai/dsh-agent-presets': ['presets'], + // The Web Host mounts the default-off settings owner independently of each + // Agent-scoped delegation-tool instance. + '@deepseek-ai/dsh-tool-subagent': ['lib/model-selection-settings.js'], // The argv-prefix runner entry ships beside the lib as its own bundle; // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index db52420840..05a4c8947c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -100,6 +100,7 @@ export const SERVICE_PAGE: Record = { spillStore: 'spill.md', storage: 'storage.md', storageDomain: 'storage.md', + subagentModelSelection: 'subagent.md', subagents: 'subagent.md', subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c5452d8d4a..a47f94b943 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -205,6 +205,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['llm-deepseek', 'llm-pi-ai', 'apiproxy'], note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer.', }, + { + key: 'subagentModelSelection', + pkg: 'tool-subagent', + title: 'Subagent model-selection preference', + mode: 'core', + consumers: ['tool-subagent'], + note: 'Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session.', + }, { key: 'credentials', pkg: 'credentials', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 19ab10c455..3d69da2cc3 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -9,6 +9,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import LlmRuntime from '@deepseek-ai/dsh-llm' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -104,7 +105,7 @@ const OUT = 'docs/tool-catalog.md' function registerCatalogSubagentProvider(ctx: Context, name: string): void { const provider: SubagentProvider = { name, - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), // Declared so consumers configured for continuable background mode mount. @@ -455,17 +456,21 @@ const TOOL_PACKAGES: ToolPackage[] = [ { pkg: '@deepseek-ai/dsh-tool-subagent', dir: 'tool-subagent', - source: 'packages/subagent/tool-subagent/src/index.ts', - requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt'], + source: { + list_subagent_models: 'packages/subagent/tool-subagent/src/list-models.ts', + subagent: 'packages/subagent/tool-subagent/src/index.ts', + }, + requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt', 'ctx.llm for model discovery and selected-route validation'], writes: ['tool/call', 'tool/result', 'child session events through the chosen provider'], shippedNames: ['subagent', 'subagent_fork'], async mount(ctx) { await ctx.plugin(SubagentRuntime) + await ctx.plugin(LlmRuntime) registerCatalogSubagentProvider(ctx, 'mock') - await ctx.plugin(ToolSubagent, { provider: 'mock' }) + await ctx.plugin(ToolSubagent, { provider: 'mock', enableModelSelection: true }) }, note: - '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, `run_in_background` parameter, and system-prompt policy follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable` and defaults omitted calls to background with automatic settlement delivery, while `subagent_fork` stays `one-shot` and defaults them to foreground — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 20aecae839..c9dc0735ea 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -2988,7 +2988,7 @@ "seq": 6, "time": 0, "data": { - "version": 2, + "version": 3, "mode": "one-shot", "provider": "spawn", "label": "Check direct child" @@ -3110,11 +3110,10 @@ "config": { "provider": "deepseek-official", "model": "smoke-model", - "maxTokens": 256000, - "reasoningEffort": "high" + "reasoningEffort": "high", + "maxTokens": 256000 }, "adapterDefaults": { - "reasoningEffort": true, "maxTokens": true }, "system": "{{system}}", @@ -3763,7 +3762,7 @@ "seq": 6, "time": 0, "data": { - "version": 2, + "version": 3, "mode": "one-shot", "provider": "spawn" } @@ -3884,11 +3883,10 @@ "config": { "provider": "deepseek-official", "model": "smoke-model", - "maxTokens": 256000, - "reasoningEffort": "high" + "reasoningEffort": "high", + "maxTokens": 256000 }, "adapterDefaults": { - "reasoningEffort": true, "maxTokens": true }, "system": "{{system}}", diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index db7a626752..39ac30a965 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -5,12 +5,12 @@ {"type":"agent/inbox/spliced","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":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","throughSeq":12}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index eed0d049f5..7593678f03 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -5,12 +5,12 @@ {"type":"agent/inbox/spliced","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":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"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":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","throughSeq":12}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2c64da3ce6..8a8bc37baa 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -108,6 +108,7 @@ "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], + "@deepseek-ai/dsh-tool-subagent/model-selection-settings": ["./packages/subagent/tool-subagent/src/model-selection-settings.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-questions/types": ["./packages/interaction/user-questions/src/types.ts"], "@deepseek-ai/dsh-agent/types": ["./packages/core/agent/src/types.ts"], From d420292400936ed062b8a5a07764b77583c59adf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 18:58:25 +0800 Subject: [PATCH 076/138] =?UTF-8?q?fix(web):=20=E5=A4=84=E7=90=86=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E5=8F=91=E7=8E=B0=E7=9A=84=E5=9B=BE=E7=89=87=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=BE=B9=E7=95=8C=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图片错误结果在 Result 页签保留错误名称与代码 - 空文本块加图片的记录按纯图片标注,不再空行 - sourceBlock 的持久化图片守卫检查 attachmentId 字段 - 移除 ui-chat 对 util/crypto 的过期 tsconfig 引用 - 同步 slots.md 层级图与 2026-08-20 所有权 Note --- ...t-session-conversation-ownership.i18n.yaml | 4 +-- ...0-client-session-conversation-ownership.md | 6 ++-- ...lient-session-conversation-ownership.zh.md | 6 ++-- ...-24-trajectory-image-attachments.i18n.yaml | 4 +-- ...2026-08-24-trajectory-image-attachments.md | 2 +- ...6-08-24-trajectory-image-attachments.zh.md | 2 +- docs/subsystems/slots.i18n.yaml | 4 +-- docs/subsystems/slots.md | 3 +- docs/subsystems/slots.zh.md | 3 +- packages/client/ui-chat/tsconfig.json | 3 -- .../src/client/TrajectoryTable.tsx | 8 +++++ .../client/ui-trajectory/src/client/layout.ts | 18 +++++++--- .../tests/layout.client.spec.tsx | 13 +++++++ .../ui-trajectory/tests/table.client.spec.tsx | 36 +++++++++++++++++++ 14 files changed, 88 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml index f616ad829c..a1245a8831 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.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-20-client-session-conversation-ownership.md -2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa -2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5 +2026-08-20-client-session-conversation-ownership.md: 12e137209bb43d21c3437d2ce5e9d2f9180bbc77 +2026-08-20-client-session-conversation-ownership.zh.md: f0d9861eeafc07481c536b85f5ba9667573a66ef diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md index 8e5521ff19..12e137209b 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -82,7 +82,7 @@ Adding a target does not add a branch to the renderer or Session Controller. The | `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results | | `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data | | `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots | -| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory | +| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache | | `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots | | `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election | | `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election | @@ -296,13 +296,13 @@ Draft and input state belong to Conversation UI and do not enter the Session sna ### Chat owner -`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache. +`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, and Tool-inspection collaboration. It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`. Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation. -The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input. +The historical-image cache moved to `ui-conversation` (`ctx.uiConversation.imageUrl`), so Chat and Trajectory share one authorized read and one browser URL per session attachment ([Trajectory durable image attachments](../feature/2026-08-24-trajectory-image-attachments.md)); draft images remain part of Conversation input. ### Trajectory owner diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md index a007a42b3d..f0d9861eea 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -82,7 +82,7 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把 | `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 | | `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 | | `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot | -| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory | +| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache | | `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot | | `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election | | `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election | @@ -296,13 +296,13 @@ Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue ### Chat owner -`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。 +`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale 和 tool inspection 协作。 它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。 Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。 -历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。 +历史图片 cache 已移入 `ui-conversation`(`ctx.uiConversation.imageUrl`),Chat 与 Trajectory 对同一会话附件共享一次授权读取和一个浏览器 URL([Trajectory 持久化图片附件](../feature/2026-08-24-trajectory-image-attachments.zh.md));Draft 图片仍属于 Conversation input。 ### Trajectory owner diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml index b82bec2f49..6b70d9c8f6 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.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-24-trajectory-image-attachments.md -2026-08-24-trajectory-image-attachments.md: 4b79ecbb113a0f36b4cf31f9b0c68cb1fc013d13 -2026-08-24-trajectory-image-attachments.zh.md: f7695ab322f80699f1f424d26d179415f0181efc +2026-08-24-trajectory-image-attachments.md: 6f89840a7d4686e45012ea2ae25f4cc939a5ca0c +2026-08-24-trajectory-image-attachments.zh.md: f1e8634639e42adc094021c03a339b8f8223bfff diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md index 4b79ecbb11..6f89840a7d 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md @@ -10,7 +10,7 @@ Trajectory did not display session images. A durable `{ type: 'image', attachmen ## Decision -- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. +- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. This partially supersedes the `ui-chat` cache ownership recorded in [client Session/Conversation ownership](../architecture/2026-08-20-client-session-conversation-ownership.md). - The gallery owner contract (`MessageImagesOwnerProps`, `RenderMessageImages`) moved to the `ui-conversation` client contract. `ui-chat` keeps its `conversation.message.images` SlotMap row over the shared owner type; `ui-trajectory` declares its own child slot `conversation.trajectory.images` with the same owner type; `ui-attachment` registers the one `MessageImages` gallery component into both keys, so loading, retry, and lightbox behavior is identical in both views. - `TrajectorySourceBlock` carries `attachment?: ImageAttachmentRef` instead of `imageSrc`/`imageAlt`. The inline-source sniffing (`sourceImage`, `safeImageSource`) and the Trajectory-local `PanelImage` renderer are removed: no producer writes inline image bytes or URLs into the session log, so those paths were dead code, and the issue explicitly excludes upload-time transient paths. - A record whose content has images but no text labels its ledger row with the locale-owned `layout.imageOnly` count; tool results with only images use the same label for their result summary instead of a JSON dump. diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md index f7695ab322..f1e8634639 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md @@ -10,7 +10,7 @@ Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: Im ## Decision -- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。 +- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。这部分取代了 [client Session/Conversation 所有权](../architecture/2026-08-20-client-session-conversation-ownership.zh.md)中记录的 `ui-chat` 缓存归属。 - 画廊 owner 契约(`MessageImagesOwnerProps`、`RenderMessageImages`)移入 `ui-conversation` 客户端契约。`ui-chat` 的 `conversation.message.images` SlotMap 行沿用共享 owner 类型;`ui-trajectory` 以同一 owner 类型声明自己的子槽位 `conversation.trajectory.images`;`ui-attachment` 把同一个 `MessageImages` 画廊组件注册进两个键,因此加载、重试与灯箱行为在两个视图中完全一致。 - `TrajectorySourceBlock` 以 `attachment?: ImageAttachmentRef` 取代 `imageSrc`/`imageAlt`。内联来源嗅探(`sourceImage`、`safeImageSource`)与 Trajectory 本地的 `PanelImage` 渲染器一并删除:没有生产方向会话日志写入内联图片字节或 URL,这些路径是死代码,且 issue 明确排除上传来源的临时路径。 - 内容含图片但没有文本的记录,其记录表行以 locale 持有的 `layout.imageOnly` 计数标注;只含图片的工具结果的摘要也使用同一标签,而不是 JSON 转储。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index ad6907574f..cda920ea03 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.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/slots.md -slots.md: d201223c9e630f16f310d8ff90318ab8c43211e5 -slots.zh.md: 23277e94e2a9e85be7f1745a172dfd9cb8a5be37 +slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee +slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index d201223c9e..6eb61780ca 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -134,7 +134,8 @@ root │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview │ │ │ └─ tool.view.cordis -│ │ └─ conversation.message.images +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage │ │ ├─ conversation.session.header.actions diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 23277e94e2..e5a25763d3 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -134,7 +134,8 @@ root │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview │ │ │ └─ tool.view.cordis -│ │ └─ conversation.message.images +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage │ │ ├─ conversation.session.header.actions diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 6f37d011f3..f819db101d 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -50,9 +50,6 @@ { "path": "../../runtime-diagnostics/invariants" }, - { - "path": "../../util/crypto" - }, { "path": "../../util/workspace-path" }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 703675795e..81a428aa35 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1160,6 +1160,8 @@ function SourceBlocks({
)} + {/* The Raw view keeps model block order and granularity: one + gallery per image block, unlike the aggregated record gallery. */} {block.attachment !== undefined ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' }) :
{block.content}
} @@ -1388,11 +1390,14 @@ function SystemPromptDiff({ function ToolOutputBlocks({ blocks, error, + errorDetail, preview, renderImages, }: { blocks: readonly TrajectorySourceBlock[] error: boolean + /** Failure name and code preserved beside image-only error content. */ + errorDetail?: string | undefined preview: boolean renderImages: RenderMessageImages }) { @@ -1403,6 +1408,8 @@ function ToolOutputBlocks({ error ? css.errorPayload : undefined, ].filter((value): value is string => value !== undefined).join(' ')} > + {error && errorDetail !== undefined && errorDetail !== '' + &&
{errorDetail}
} {blocks.map((block, index) => ( block.attachment !== undefined ? ( @@ -1630,6 +1637,7 @@ function RecordPayload({ diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 0283f4fde3..a811e915f2 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -120,7 +120,10 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< | 'timeSeconds' | 'startedAt' > { - const previewMarkdown = previewContent(node.content) + // An empty text block yields an empty preview; treat it as absent so an + // image-bearing record still labels its row instead of rendering blank. + const preview = previewContent(node.content) + const previewMarkdown = preview === '' ? undefined : preview const images = imageBlockCount(node.content) return { text: previewMarkdown === undefined && images > 0 @@ -792,7 +795,7 @@ function summarizeAssistantActivity( if (tools.size > 0) { return t('layout.toolCallOnly') } - const images = imageBlockCount(blocks.map(block => ({ type: block.kind }))) + const images = blocks.filter(block => block.kind === 'image').length if (images > 0) return t('layout.imageOnly', { count: images }) return '' } @@ -828,9 +831,14 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { if (typeof block.text === 'string') { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } - if (type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - // Typed content only reaches here as a core ImageBlock; wire-shaped - // 'other' blocks never define `attachment`. + if ( + type === 'image' + && typeof block.attachment === 'object' && block.attachment !== null + && typeof (block.attachment as Record).attachmentId === 'string' + ) { + // Session-log content is validated into core ContentBlocks by the + // Conversation node assembly; the `attachmentId` guard only keeps + // wire-shaped 'other' blocks with an unrelated `attachment` member out. return { type, content: '', attachment: block.attachment as ImageAttachmentRef } } return { type, content: stringifySourceValue(value) } diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index bd834cbdb0..fbb454decf 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -594,6 +594,19 @@ describe('durable image attachments', () => { ]) }) + it('labels a record whose only text block is empty as image-only', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'text', text: '' }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('Images ×1') + expect(user?.previewMarkdown).toBeUndefined() + }) + it('keeps the text preview when a user message mixes text and images', () => { const nodes = [ { diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 3d3955dcb3..9bd8de74f0 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -1035,6 +1035,42 @@ describe('TrajectoryTable', () => { .toBe(String(attachment.attachmentId)) }) + it('keeps the failure name beside an image-only error result', () => { + const attachment = { + attachmentId: `sha256:${'c'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 320, + height: 320, + name: 'failed.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'render {"target":"chart"}', + outputDetail: 'ToolError: RENDER_TRUNCATED', + outputBlocks: [{ type: 'image', content: '', attachment }], + isError: true, + timeSeconds: 0.1, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Result' })) + + expect(screen.getByText('ToolError: RENDER_TRUNCATED')).toBeTruthy() + const gallery = screen.getAllByTestId('record-images').at(-1) + expect(gallery?.getAttribute('data-count')).toBe('1') + }) + it('keeps the first row and a compact summary when a turn is collapsed', () => { render( Date: Sun, 23 Aug 2026 15:39:18 +0800 Subject: [PATCH 077/138] feat(python-runtime): package the Windows x64 dsh executable Add node24-win-x64 as the only supported Windows runtime target and publish it as a py3-none-win_amd64 wheel containing the conventional dsh and ripgrep .exe payload names. Keep Windows ARM64 rejected explicitly so Python cannot claim a carrier that CI and release automation do not build. Teach the pkg builder to require a native x64 Windows host, validate both node-pty ConPTY addons, copy @vscode's win32 ripgrep executable, and recognize pkg's .exe output. Extend runtime resolution, wheel staging, payload validation, and the preset closure check so the Windows-specific PowerShell plugins and sidecars fail loud when omitted. The sidecar resolver now maps a packaged main.exe to main-rg.exe; focused TypeScript and Python tests cover that name, the win_amd64 manifest, x64-only host selection, complete wheel payload, ConPTY inventory, and platform-conditioned plugin closure. --- packages/fs/tool-fs-search/src/search-core.ts | 7 +- .../tool-fs-search/tests/rg-sidecar.spec.ts | 29 +++++- pnpm-lock.yaml | 6 ++ python/sdk-runtime/hatch_build.py | 19 +++- python/sdk-runtime/package.json | 2 + python/sdk-runtime/platforms.json | 4 + .../src/deepseek_harness_runtime/__init__.py | 22 +++-- python/sdk/src/deepseek_harness/client.py | 19 +++- python/sdk/tests/test_client.py | 37 ++++++++ python/sdk/tests/test_release_version.py | 19 +++- python/sdk/tests/test_runtime_resolution.py | 24 +++++ ...uild-exe-for-python-sdk-native-pty.spec.ts | 20 +++- .../build-exe-for-python-sdk-native-pty.ts | 22 +++++ scripts/build-exe-for-python-sdk.spec.ts | 81 ++++++++++++++++ scripts/build-exe-for-python-sdk.ts | 92 +++++++++++++++---- scripts/build-python-release.py | 18 ++-- scripts/verify-runtime-closure.spec.ts | 11 ++- scripts/verify-runtime-closure.ts | 1 + 18 files changed, 380 insertions(+), 53 deletions(-) create mode 100644 scripts/build-exe-for-python-sdk.spec.ts diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 5ac5521033..60ea042d4f 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -20,7 +20,7 @@ */ import { existsSync } from 'node:fs' -import { isAbsolute, relative, sep } from 'node:path' +import { isAbsolute, join, parse, relative, sep } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-output-retention' @@ -170,7 +170,10 @@ let rgPathPromise: Promise | undefined */ export function resolveRgPath(): Promise { rgPathPromise ??= Promise.resolve().then(async () => { - const executableSidecar = `${process.execPath}-rg` + const executable = parse(process.execPath) + const executableSidecar = process.platform === 'win32' + ? join(executable.dir, `${executable.name}-rg.exe`) + : `${process.execPath}-rg` if ('pkg' in process && existsSync(executableSidecar)) return executableSidecar return (await import('@vscode/ripgrep')).rgPath }) diff --git a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts index 53c7a01aea..d3a6b5e188 100644 --- a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts +++ b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts @@ -1,9 +1,12 @@ +import { join, parse } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { dependencyRgPath, existsSync } = vi.hoisted(() => ({ dependencyRgPath: '/node_modules/@vscode/ripgrep/bin/rg', existsSync: vi.fn(), })) +const originalPlatform = process.platform +const originalExecPath = process.execPath vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -16,17 +19,35 @@ beforeEach(() => { vi.resetModules() existsSync.mockReset() Reflect.deleteProperty(process, 'pkg') + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform }) + process.execPath = originalExecPath }) afterEach(() => { Reflect.deleteProperty(process, 'pkg') + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform }) + process.execPath = originalExecPath }) describe('ripgrep resolution', () => { it('uses the native sidecar beside the current executable', async () => { Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: 'linux' }) + process.execPath = '/runtime/dsh' existsSync.mockReturnValue(true) - const sidecar = `${process.execPath}-rg` + const sidecar = '/runtime/dsh-rg' + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') + + await expect(resolveRgPath()).resolves.toBe(sidecar) + expect(existsSync).toHaveBeenCalledWith(sidecar) + }) + + it('uses a conventional executable name for the Windows ripgrep sidecar', async () => { + Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: 'win32' }) + process.execPath = 'C:\\runtime\\deepseek-harness-sdk-runtime-win-x64.exe' + existsSync.mockReturnValue(true) + const sidecar = 'C:\\runtime\\deepseek-harness-sdk-runtime-win-x64-rg.exe' const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') await expect(resolveRgPath()).resolves.toBe(sidecar) @@ -47,6 +68,10 @@ describe('ripgrep resolution', () => { const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') await expect(resolveRgPath()).resolves.toBe(dependencyRgPath) - expect(existsSync).toHaveBeenCalledWith(`${process.execPath}-rg`) + const executable = parse(process.execPath) + const sidecar = process.platform === 'win32' + ? join(executable.dir, `${executable.name}-rg.exe`) + : `${process.execPath}-rg` + expect(existsSync).toHaveBeenCalledWith(sidecar) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6878a775b..f7f70486da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9972,6 +9972,12 @@ importers: '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../packages/jobs/tool-jobs + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index c4083387a6..22d0457d86 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -39,7 +39,15 @@ def _host_platform_tag() -> str: machine = platform.machine().lower() arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine system = platform.system().lower() - key = f"macos-{arch}" if system == "darwin" else f"linux-{arch}" if system == "linux" else system + key = ( + f"macos-{arch}" + if system == "darwin" + else f"linux-{arch}" + if system == "linux" + else f"win-{arch}" + if system == "windows" + else system + ) try: return _PLATFORMS[key][0] except KeyError as exc: @@ -69,16 +77,21 @@ class RuntimeBuildHook(BuildHookInterface): runtime_files = sorted( runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] ) - expected_files = [expected_executable, f"{expected_executable}-rg"] + expected_files = ( + [expected_executable, f"{expected_executable.removesuffix('.exe')}-rg.exe"] + if expected_executable.endswith(".exe") + else [expected_executable, f"{expected_executable}-rg"] + ) if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") + expected_files.sort() found_files = [path.name for path in runtime_files] if found_files != expected_files: raise RuntimeError( f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}" ) for executable in runtime_files: - if executable.stat().st_mode & stat.S_IXUSR == 0: + if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5684867e6..331d388a0f 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-persona": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", @@ -103,6 +104,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", diff --git a/python/sdk-runtime/platforms.json b/python/sdk-runtime/platforms.json index e65cd6a735..9c0a1fec72 100644 --- a/python/sdk-runtime/platforms.json +++ b/python/sdk-runtime/platforms.json @@ -10,5 +10,9 @@ "macos-arm64": { "tag": "macosx_14_0_arm64", "executable": "deepseek-harness-sdk-runtime-macos-arm64" + }, + "win-x64": { + "tag": "win_amd64", + "executable": "deepseek-harness-sdk-runtime-win-x64.exe" } } diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 0fc4f416c0..4834a029d1 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -4,9 +4,10 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's ``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): - **exe (production)**: single-file Node executables named - ``deepseek-harness-sdk-runtime--`` (platform in {linux, macos}, arch in - {x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling - ``-spawn-helper``. The target machine needs no Node installation. + ``deepseek-harness-sdk-runtime--`` for Linux/macOS and an + ``.exe`` counterpart for Windows. Each has a sibling ripgrep executable; + macOS also uses a sibling ``-spawn-helper``. The target machine needs no + Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`` on a @@ -30,7 +31,7 @@ PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" -_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos"} +_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos", "win32": "win"} _ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"} _EXE_ACQUISITION_HINT = ( @@ -62,13 +63,18 @@ def bundled_runtime_path() -> Path: touching callers). """ tag = _current_platform_tag() - path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}" + extension = ".exe" if tag.startswith("win-") else "" + path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}{extension}" if not path.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) - ripgrep = Path(f"{path}-rg") + ripgrep = ( + path.with_name(f"{path.stem}-rg.exe") + if tag.startswith("win-") + else Path(f"{path}-rg") + ) if not ripgrep.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the ripgrep sidecar at {ripgrep}. " @@ -110,11 +116,11 @@ def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]: def _current_platform_tag() -> str: plat = _PLATFORM_TAGS.get(sys.platform) arch = _ARCH_TAGS.get(platform.machine().lower()) - if plat is None or arch is None: + if plat is None or arch is None or (plat == "win" and arch != "x64"): raise FileNotFoundError( "no bundled DeepSeek Harness SDK runtime exists for this platform " f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " - "linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT + "Linux x64/arm64, macOS arm64, and Windows x64. " + _EXE_ACQUISITION_HINT ) return f"{plat}-{arch}" diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 5978d849ab..f6752a9906 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -93,11 +93,14 @@ class HarnessClient: self._start_stderr_thread() def close(self) -> None: + """Close the runtime after a bounded opportunity to flush durable state.""" proc = self._proc if proc is None: return + shutdown_completed = False try: self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds) + shutdown_completed = True except Exception as exc: self._stderr_lines.append(f"shutdown request failed: {exc}") if proc.stdin: @@ -105,16 +108,22 @@ class HarnessClient: proc.stdin.close() except Exception as exc: self._stderr_lines.append(f"stdin close failed: {exc}") + if shutdown_completed: + try: + proc.wait(timeout=self.config.shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + pass if proc.poll() is None: try: proc.terminate() except ProcessLookupError: pass - try: - proc.wait(timeout=self.config.shutdown_timeout_seconds) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() + if proc.poll() is None: + try: + proc.wait(timeout=self.config.shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() self._proc = None self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed")) if self._reader_thread and self._reader_thread.is_alive(): diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 1ad89ffd4b..fba315320b 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -783,6 +783,43 @@ for line in sys.stdin: assert client._proc is None +def test_client_close_allows_eof_quiescence_after_shutdown_response(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + marker = tmp_path / "quiesced.txt" + script.write_text( + """ +import json +import os +from pathlib import Path +import sys +import time + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + +time.sleep(0.05) +Path(os.environ["QUIESCED_MARKER"]).write_text("quiesced") +""".strip() + ) + + client = HarnessClient( + HarnessConfig( + _launch_args=(sys.executable, str(script)), + env={"QUIESCED_MARKER": str(marker)}, + shutdown_timeout_seconds=1, + ) + ) + client.start() + client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") + client.close() + + assert marker.read_text() == "quiesced" + + def test_initialize_failure_reaps_started_runtime(tmp_path: Path) -> None: script = tmp_path / "rejecting_runtime.py" script.write_text( diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index deaa65b8a8..829c3b73f8 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -62,6 +62,14 @@ def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None: assert build_python_release.PLATFORMS["macos-arm64"][1] == "deepseek-harness-sdk-runtime-macos-arm64" +def test_windows_wheel_tag_and_payload_are_x64_only() -> None: + assert build_python_release.PLATFORMS["win-x64"] == ( + "win_amd64", + "deepseek-harness-sdk-runtime-win-x64.exe", + ) + assert not any(name.startswith("win-") and name != "win-x64" for name in build_python_release.PLATFORMS) + + def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None: manifest = tmp_path / "platforms.json" manifest.write_text('{"macos-arm64":{"tag":"macosx_14_0_arm64"}}\n') @@ -85,7 +93,10 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() -@pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) +@pytest.mark.parametrize( + ("target", "with_helper"), + [("linux-x64", False), ("macos-arm64", True), ("win-x64.exe", False)], +) def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool ) -> None: @@ -93,7 +104,11 @@ def test_stage_runtime_copies_platform_payload( executable.write_bytes(b"runtime") executable.chmod(0o755) expected = {executable.name: b"runtime"} - ripgrep = Path(f"{executable}-rg") + ripgrep = ( + executable.with_name(f"{executable.stem}-rg.exe") + if executable.suffix == ".exe" + else Path(f"{executable}-rg") + ) ripgrep.write_bytes(b"ripgrep") ripgrep.chmod(0o755) expected[ripgrep.name] = b"ripgrep" diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index fc54171b75..beaf5cfd6b 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -55,6 +55,30 @@ def test_runtime_requires_spawn_helper_only_on_macos( assert runtime.bundled_runtime_path() == linux +def test_windows_runtime_uses_exe_payload_and_exe_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + executable = runtime_dir / "deepseek-harness-sdk-runtime-win-x64.exe" + executable.touch() + (runtime_dir / "deepseek-harness-sdk-runtime-win-x64-rg.exe").touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "win-x64") + + assert runtime.bundled_runtime_path() == executable + + +def test_current_platform_supports_windows_x64_only(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(runtime.sys, "platform", "win32") + monkeypatch.setattr(runtime.platform, "machine", lambda: "AMD64") + assert runtime._current_platform_tag() == "win-x64" + + monkeypatch.setattr(runtime.platform, "machine", lambda: "ARM64") + with pytest.raises(FileNotFoundError, match="Windows x64"): + runtime._current_platform_tag() + + def test_runtime_requires_ripgrep_sidecar( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/scripts/build-exe-for-python-sdk-native-pty.spec.ts b/scripts/build-exe-for-python-sdk-native-pty.spec.ts index 5dd6588955..cc7d0ef7fa 100644 --- a/scripts/build-exe-for-python-sdk-native-pty.spec.ts +++ b/scripts/build-exe-for-python-sdk-native-pty.spec.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts' +import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' const roots: string[] = [] @@ -35,6 +35,24 @@ describe('resolveLinuxNodePtyAddon', () => { }) }) +describe('resolveWindowsNodePtyAddons', () => { + it('requires both ConPTY addons from the x64 prebuild', () => { + const root = temporaryPackage() + const conpty = createAddon(root, 'prebuilds', 'win32-x64', 'conpty.node') + const consoleList = createAddon(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node') + + expect(resolveWindowsNodePtyAddons(root, 'x64')).toEqual([conpty, consoleList]) + }) + + it('names every missing Windows addon', () => { + const root = temporaryPackage() + + expect(() => resolveWindowsNodePtyAddons(root, 'x64')).toThrow( + `Windows node-pty addons are missing: ${join(root, 'prebuilds', 'win32-x64', 'conpty.node')}, ${join(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node')}`, + ) + }) +}) + function temporaryPackage(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-node-pty-addon-')) roots.push(root) diff --git a/scripts/build-exe-for-python-sdk-native-pty.ts b/scripts/build-exe-for-python-sdk-native-pty.ts index 02fa864d73..3ce5295d5c 100644 --- a/scripts/build-exe-for-python-sdk-native-pty.ts +++ b/scripts/build-exe-for-python-sdk-native-pty.ts @@ -21,3 +21,25 @@ export function resolveLinuxNodePtyAddon( `build-exe-for-python-sdk: node-pty addon is absent from both ${built} and ${prebuilt}.`, ) } + +/** + * Require both node-pty addons used by the Windows ConPTY backend. + * @param packageDirectory - staged node-pty package directory. + * @param arch - Windows target architecture. + * @returns the existing addon paths in load order. + */ +export function resolveWindowsNodePtyAddons( + packageDirectory: string, + arch: 'x64', +): string[] { + const directory = join(packageDirectory, 'prebuilds', `win32-${arch}`) + const addons = [ + join(directory, 'conpty.node'), + join(directory, 'conpty_console_list.node'), + ] + const missing = addons.filter(path => !existsSync(path)) + if (missing.length > 0) { + throw new Error(`build-exe-for-python-sdk: Windows node-pty addons are missing: ${missing.join(', ')}.`) + } + return addons +} diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts new file mode 100644 index 0000000000..c3fe4a15a6 --- /dev/null +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -0,0 +1,81 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '..') +const script = resolve(root, 'scripts/build-exe-for-python-sdk.ts') +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function run(env: NodeJS.ProcessEnv, ...args: string[]) { + return spawnSync(process.execPath, ['--import', 'tsx/esm', script, ...args], { + cwd: root, + encoding: 'utf8', + env: isolatedPnpmEnvironment(env), + }) +} + +describe('Python runtime executable builder CLI', () => { + it('runs pnpm through its JavaScript entrypoint without a command shell', () => { + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cjs' }, + '--skip-build', + '--dry-run', + '--targets=node24-macos-arm64', + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs dlx @yao-pkg/pkg@6.21.0`) + expect(result.stdout).not.toMatch(/pnpm\.cmd/i) + }) + + it('resolves the pnpm package behind a Windows command shim', () => { + const setup = mkdtempSync(join(tmpdir(), 'dsh-pnpm-home-')) + temporaryDirectories.push(setup) + const home = join(setup, 'node_modules', '.bin') + const entrypoint = join(setup, 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') + mkdirSync(home, { recursive: true }) + mkdirSync(dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '') + + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cmd', PNPM_HOME: home }, + '--skip-build', + '--dry-run', + '--targets=node24-macos-arm64', + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`${process.execPath} ${entrypoint} run verify-runtime-closure`) + expect(result.stdout).not.toMatch(/pnpm\.cmd/i) + }) + + it('rejects a Windows arm64 product before any build step', () => { + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cjs' }, + '--skip-build', + '--dry-run', + '--targets=node24-win-arm64', + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Windows supports x64 only') + expect(result.stdout).toBe('') + }) +}) + +function isolatedPnpmEnvironment(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !['npm_execpath', 'pnpm_home'].includes(key.toLowerCase())), + ) + return { ...environment, ...overrides } +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index c8c30b4301..c7c8cfed66 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -9,9 +9,9 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, join, resolve, sep } from 'node:path' +import { basename, dirname, extname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' -import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts' +import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' const root = resolve(import.meta.dirname, '..') @@ -63,7 +63,7 @@ const ASSET_GLOBS = [ 'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*', ] -const PLATFORMS = ['linux', 'macos'] as const +const PLATFORMS = ['linux', 'macos', 'win'] as const const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] @@ -83,10 +83,7 @@ class Target { private constructor( /** pkg Node range (`node`). */ readonly nodeRange: string, - /** - * pkg platform tag. Windows is a documented non-goal - * (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). - */ + /** pkg platform tag. */ readonly platform: Platform, /** pkg CPU tag. */ readonly arch: Arch, @@ -117,6 +114,9 @@ class Target { if (!isArch(arch)) { throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`) } + if (platform === 'win' && arch !== 'x64') { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: Windows supports x64 only.`) + } return new Target(nodeRange, platform, arch) } @@ -125,7 +125,13 @@ class Target { * @returns the host target; throws on an unsupported host platform or arch. */ static host(): Target { - const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined + const platform = process.platform === 'darwin' + ? 'macos' + : process.platform === 'linux' + ? 'linux' + : process.platform === 'win32' + ? 'win' + : undefined if (platform === undefined) { throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`) } @@ -133,6 +139,9 @@ class Target { if (arch === undefined) { throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`) } + if (platform === 'win' && arch !== 'x64') { + throw new Error('build-exe-for-python-sdk: Windows supports x64 only; use an x64 Node process.') + } return new Target(DEFAULT_NODE_RANGE, platform, arch) } } @@ -200,7 +209,7 @@ class BuildCli { return [ 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]', '', - ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.', + ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64.', ' Default: the host platform only (on node24).', ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).', ' --dry-run print every command and config patch without executing.', @@ -212,8 +221,27 @@ class BuildCli { } } -function pnpmBin(): string { - return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +function pnpmInvocation(args: string[]): [command: string, args: string[]] { + const entrypoint = process.env.npm_execpath?.trim() + if (entrypoint !== undefined && entrypoint !== '') { + const extension = extname(entrypoint).toLowerCase() + if (extension === '.js' || extension === '.cjs' || extension === '.mjs') { + return [process.execPath, [entrypoint, ...args]] + } + if (extension !== '.cmd') return [entrypoint, args] + } + const home = process.env.PNPM_HOME?.trim() + if (home !== undefined && home !== '') { + const packageBin = resolve(home, '..', 'pnpm', 'bin') + for (const filename of ['pnpm.mjs', 'pnpm.cjs']) { + const candidate = resolve(packageBin, filename) + if (existsSync(candidate)) return [process.execPath, [candidate, ...args]] + } + } + if (process.platform === 'win32') { + throw new Error('build-exe-for-python-sdk: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.') + } + return ['pnpm', args] } /** @@ -241,7 +269,7 @@ class SingleExeBuild { /** Verify the closure before compiling or packaging. */ async verifyClosure(): Promise { - await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure']) + await this.runPnpm('runtime dependency closure', ['run', 'verify-runtime-closure']) } /** Build all package artifacts unless `--skip-build` was passed. */ @@ -250,7 +278,7 @@ class SingleExeBuild { console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)') return } - await this.run('build', pnpmBin(), ['run', 'build']) + await this.runPnpm('build', ['run', 'build']) } /** Clear and deploy the runtime closure into the node carrier. */ @@ -260,7 +288,7 @@ class SingleExeBuild { } if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`) else await rm(this.staging, { recursive: true, force: true }) - await this.run('deploy', pnpmBin(), [ + await this.runPnpm('deploy', [ '--filter', DEPLOY_ROOT_PACKAGE, 'deploy', @@ -393,10 +421,11 @@ class SingleExeBuild { * @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required. */ async pack(target: Target): Promise { - const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const product = target.platform === 'win' ? `${productBase}.exe` : productBase await this.prepareNativePty(target) if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true }) - await this.run(`pkg ${target.spec}`, pnpmBin(), [ + await this.runPnpm(`pkg ${target.spec}`, [ 'dlx', PKG_SPEC, this.staging, @@ -424,16 +453,19 @@ class SingleExeBuild { /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */ private async copyRipgrepSidecar(target: Target, product: string): Promise { - const platform = target.platform === 'macos' ? 'darwin' : target.platform + const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform + const executable = target.platform === 'win' ? 'rg.exe' : 'rg' const source = join( this.staging, 'node_modules', '@vscode', `ripgrep-${platform}-${target.arch}`, 'bin', - 'rg', + executable, ) - const destination = `${product}-rg` + const destination = target.platform === 'win' + ? `${product.slice(0, -'.exe'.length)}-rg.exe` + : `${product}-rg` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return destination @@ -455,7 +487,6 @@ class SingleExeBuild { const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build') if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - if (target.platform !== 'linux') return const packageDirectory = join( root, 'packages', @@ -464,6 +495,21 @@ class SingleExeBuild { 'node_modules', 'node-pty', ) + if (target.platform === 'win') { + if (target.arch !== 'x64') { + throw new Error('build-exe-for-python-sdk: Windows supports x64 only.') + } + const host = Target.host() + if (target.platform !== host.platform || target.arch !== host.arch) { + throw new Error( + 'build-exe-for-python-sdk: build the Windows runtime under x64 Node on its target host; ' + + `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`, + ) + } + resolveWindowsNodePtyAddons(join(this.staging, 'node_modules', 'node-pty'), target.arch) + return + } + if (target.platform !== 'linux') return const destination = join(stagedBuild, 'Release', 'pty.node') const source = resolveLinuxNodePtyAddon(packageDirectory, target.arch) if (this.cli.dryRun) { @@ -553,6 +599,12 @@ class SingleExeBuild { }) }) } + + /** Run pnpm through its JavaScript entrypoint when the caller supplies one. */ + private async runPnpm(label: string, args: string[]): Promise { + const [command, invocationArgs] = pnpmInvocation(args) + await this.run(label, command, invocationArgs) + } } async function main(): Promise { diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index c546c6bb42..307fe759dd 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -47,9 +47,12 @@ def load_platforms(path: Path = PLATFORM_MANIFEST) -> dict[str, tuple[str, str]] PLATFORMS = load_platforms() -def runtime_suffixes(executable_name: str) -> tuple[str, ...]: - suffixes = ("", "-rg") - return (*suffixes, "-spawn-helper") if "-macos-" in executable_name else suffixes +def runtime_filenames(executable_name: str) -> tuple[str, ...]: + """Return the exact platform payload names for one runtime executable.""" + if executable_name.endswith(".exe"): + return (executable_name, f"{executable_name.removesuffix('.exe')}-rg.exe") + names = (executable_name, f"{executable_name}-rg") + return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names def main() -> None: @@ -210,8 +213,9 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) - for suffix in runtime_suffixes(executable_name): - shutil.copy2(Path(f"{executable}{suffix}"), runtime_dir / f"{executable_name}{suffix}") + source_directory = executable.parent + for filename in runtime_filenames(executable_name): + shutil.copy2(source_directory / filename, runtime_dir / filename) def verify_wheel( @@ -250,13 +254,13 @@ def verify_wheel( ] if package == "runtime": assert platform is not None - expected_files = [f"{platform[1]}{suffix}" for suffix in runtime_suffixes(platform[1])] + expected_files = sorted(runtime_filenames(platform[1])) found_files = sorted(Path(name).name for name in runtime_files) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") for runtime_file in runtime_files: mode = archive.getinfo(runtime_file).external_attr >> 16 - if mode & stat.S_IXUSR == 0: + if platform[0] != "win_amd64" and mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}") elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 6a395afe30..90dbf20799 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -21,6 +21,7 @@ const platforms = { 'linux-x64': { tag: 'manylinux_2_28_x86_64', executable: 'runtime-linux-x64' }, 'linux-arm64': { tag: 'manylinux_2_28_aarch64', executable: 'runtime-linux-arm64' }, 'macos-arm64': { tag: 'macosx_14_0_arm64', executable: 'runtime-macos-arm64' }, + 'win-x64': { tag: 'win_amd64', executable: 'runtime-win-x64.exe' }, } function workspace(root: string, name: string, manifest: Record): void { @@ -35,7 +36,7 @@ afterEach(() => { }) describe('verifyRuntimeClosure', () => { - it('requires only plugins active for a Linux or macOS target', async () => { + it('requires only plugins active for each published target', async () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, @@ -52,6 +53,9 @@ describe('verifyRuntimeClosure', () => { - id: macos name: '@scope/macos' disabled: !!js process.platform !== 'darwin' + - id: windows + name: '@scope/windows' + disabled: !!js process.platform !== 'win32' `, }) @@ -61,6 +65,7 @@ describe('verifyRuntimeClosure', () => { expect(result.failures).toEqual([ 'standard preset -> @scope/linux (linux-arm64, linux-x64)', 'standard preset -> @scope/macos (macos-arm64)', + 'standard preset -> @scope/windows (win-x64)', ]) }) @@ -78,7 +83,7 @@ describe('verifyRuntimeClosure', () => { const result = await verifyRuntimeClosure(root) expect(result.failures).toEqual([ - 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64)', + 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64, win-x64)', ]) }) @@ -112,7 +117,7 @@ describe('verifyRuntimeClosure', () => { const result = await verifyRuntimeClosure(root) expect(result.failures).toEqual([ - 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64)', + 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64, win-x64)', ]) }) diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index d0127fc68d..b0dac1454b 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -181,6 +181,7 @@ function disabledOnPlatform(value: unknown, processPlatform: string): boolean { function processPlatformForTarget(target: string): string { if (target.startsWith('linux-')) return 'linux' if (target.startsWith('macos-')) return 'darwin' + if (target.startsWith('win-')) return 'win32' throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`) } From 026a37fc070d5a7e4416d76bfd6eb9b2e184f6e3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:39:43 +0800 Subject: [PATCH 078/138] ci(python): gate the Windows x64 installed wheel Add node24-win-x64 to the required pull-request and public-release matrices on a native windows-2025 runner, and publish the same win_amd64 artifact from the GitLab tag pipeline. GitHub uses Git Bash for the shared release script while selecting the Windows venv's Scripts/python.exe explicitly; the Linux and macOS legs retain their existing commands and native checks. Run the complete installed-wheel keyless suite and the trusted two-turn DeepSeek smoke on Windows exactly as on the existing targets. Make the minimal blackbox choose persistent PowerShell on Windows, keep advanced and restart snapshots platform-stable by disabling both one-shot shell variants, locate the generated dsh.exe console command, and validate text lines without assuming POSIX newlines. Workflow tests pin the four-target matrix, Windows runner and wheel tag, cross-platform venv selection, GitLab publication dependency, and full blackbox invocation. The existing POSIX minimal snapshot changes only its platform-neutral prompt wording; Windows owns a separate model-visible snapshot. --- .../workflows/build-exe-for-python-sdk.yml | 62 ++- .github/workflows/ci.yml | 2 +- .github/workflows/python-release.yml | 5 +- .gitlab-ci.yml | 41 +- scripts/ci-workflow.spec.ts | 30 +- scripts/smoke-python-runtime.py | 56 ++- .../minimal/model-visible.json | 8 +- .../minimal/win-x64/model-visible.json | 430 ++++++++++++++++++ 8 files changed, 586 insertions(+), 48 deletions(-) create mode 100644 scripts/snapshots/python-sdk-single-exe/minimal/win-x64/model-visible.json diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 04501d5deb..056b7a2a2c 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -2,7 +2,7 @@ name: Build single-exe # Native builds for the release targets; see # .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md. -# A full target run retains one SDK wheel and three runtime wheels; subset +# A full target run retains one SDK wheel and four runtime wheels; subset # dispatch retains the SDK wheel and selected runtime wheels. Bare executables # and source closures are test inputs. Run manually, label a PR `build-exe` # (remove and reapply to rerun), or call it from the Python release workflow. @@ -11,7 +11,7 @@ on: workflow_call: inputs: targets: - description: Comma-separated pkg targets to build; empty builds all three. + description: Comma-separated pkg targets to build; empty builds all four. type: string required: false default: '' @@ -34,8 +34,8 @@ on: targets: description: >- Comma-separated pkg targets to build. Any subset of: - node24-linux-x64, node24-linux-arm64, node24-macos-arm64. - Empty builds all three. + node24-linux-x64, node24-linux-arm64, node24-macos-arm64, + node24-win-x64. Empty builds all four. type: string required: false default: '' @@ -90,7 +90,7 @@ jobs: id: plan env: # Label runs and blank dispatch inputs build all targets. - TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64' }} + TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64' }} run: | set -euo pipefail matrix='[]' @@ -104,8 +104,9 @@ jobs: node24-linux-x64) runner=ubuntu-latest ;; node24-linux-arm64) runner=ubuntu-24.04-arm ;; node24-macos-arm64) runner=macos-latest ;; + node24-win-x64) runner=windows-2025 ;; *) - echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64." + echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64, node24-win-x64." exit 1 ;; esac @@ -155,10 +156,22 @@ jobs: fail-fast: false matrix: include: ${{ fromJSON(needs.plan.outputs.matrix) }} + defaults: + run: + shell: bash steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm-js + + - name: Enable Windows Developer Mode (symlink support) + if: runner.os == 'Windows' + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" # setup-node's built-in pnpm store cache keys on platform AND arch, so # the Linux architectures sharing runner.os stay on separate caches. @@ -198,6 +211,7 @@ jobs: *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; esac addon_dir="$(realpath packages/subprocess/subprocess-local/node_modules/node-pty)" + pnpm_setup_root="$(realpath "$(dirname "$(dirname "$PNPM_HOME")")")" (cd "$addon_dir" && npm_config_build_from_source=true pnpm run install) addon="$addon_dir/build/Release/pty.node" [ -f "$addon_dir/build/Makefile" ] || { @@ -208,7 +222,7 @@ jobs: --user "$(id -u):$(id -g)" \ -v "$PWD:$PWD" \ -v "$HOME/.cache/node-gyp:$HOME/.cache/node-gyp:ro" \ - -v "$HOME/setup-pnpm:$HOME/setup-pnpm:ro" \ + -v "$pnpm_setup_root:$pnpm_setup_root:ro" \ -w "$addon_dir" \ "$image" \ bash -euxo pipefail -c \ @@ -236,13 +250,21 @@ jobs: set -euo pipefail platform="${TARGET#node24-}" exe="$PWD/dist-exe/deepseek-harness-sdk-runtime-$platform" - [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } case "$platform" in linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;; macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl ;; + win-x64) + exe="$exe.exe" + wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-win_amd64.whl + ;; *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; esac + if [ "$RUNNER_OS" = Windows ]; then + [ -f "$exe" ] || { echo "::error::$exe missing"; exit 1; } + else + [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } + fi echo "platform=$platform" >> "$GITHUB_OUTPUT" echo "exe=$exe" >> "$GITHUB_OUTPUT" echo "wheel=$wheel" >> "$GITHUB_OUTPUT" @@ -261,24 +283,32 @@ jobs: path: dist-python - name: Install local SDK and runtime wheels into a clean venv + id: smoke-venv env: RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail - python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ + venv="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-smoke-"))')" + python -m venv "$venv" + if [ "$RUNNER_OS" = Windows ]; then + smoke_python="$(cygpath -u "$venv")/Scripts/python.exe" + else + smoke_python="$venv/bin/python" + fi + "$smoke_python" -m pip install \ "dist-python/$SDK_WHEEL" \ "dist-python/$RUNTIME_WHEEL" + echo "python=$smoke_python" >> "$GITHUB_OUTPUT" - name: Run installed-wheel keyless black-box tests run: | set -euo pipefail - blackbox_root="$RUNNER_TEMP/dsh-sdk-blackbox" - mkdir -p "$blackbox_root" + blackbox_root="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-"))')" + if [ "$RUNNER_OS" = Windows ]; then blackbox_root="$(cygpath -u "$blackbox_root")"; fi cd "$blackbox_root" env -u PYTHONPATH -u DSH_RUNTIME_MODE \ - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" \ + "${{ steps.smoke-venv.outputs.python }}" \ "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ --scenario all \ --installed-wheel @@ -309,11 +339,11 @@ jobs: DEEPSEEK_BASE_URL: https://api.deepseek.com run: | set -euo pipefail - blackbox_root="$RUNNER_TEMP/dsh-sdk-blackbox-live" - mkdir -p "$blackbox_root" + blackbox_root="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-live-"))')" + if [ "$RUNNER_OS" = Windows ]; then blackbox_root="$(cygpath -u "$blackbox_root")"; fi cd "$blackbox_root" env -u PYTHONPATH -u DSH_RUNTIME_MODE \ - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" \ + "${{ steps.smoke-venv.outputs.python }}" \ "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ --scenario sdk-live \ --installed-wheel diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9d4cac33a..429796c3b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,7 +302,7 @@ jobs: name: python runtime / release-shaped matrix uses: ./.github/workflows/build-exe-for-python-sdk.yml with: - targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64 ci: true secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index d888d17a8a..18524523b9 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -24,10 +24,10 @@ concurrency: jobs: build: - name: Build four wheels + name: Build five wheels uses: ./.github/workflows/build-exe-for-python-sdk.yml with: - targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64 release: true python-compat: @@ -151,6 +151,7 @@ jobs: "deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl" \ "deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl" \ "deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl" \ + "deepseek_harness_runtime_bin-$VERSION-py3-none-win_amd64.whl" \ "deepseek_harness_sdk-$VERSION-py3-none-any.whl" > "$expected" find dist -maxdepth 1 -type f -name '*.whl' -exec basename {} \; | sort > "$actual" diff -u "$expected" "$actual" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index faa9402c42..0a663cadf7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -97,6 +97,42 @@ runtime-macos-arm64: - job: sdk-wheel artifacts: true +runtime-windows-x64: + stage: build + tags: [windows-x64] + variables: + PKG_TARGET: node24-win-x64 + PLATFORM: win-x64 + needs: + - job: sdk-wheel + artifacts: true + before_script: + - python -m venv .ci-python + - $env:DSH_VERSION = (& .ci-python\Scripts\python.exe -c 'import json; print(json.load(open("package.json"))["version"])') + - $env:DSH_WHEEL_VERSION = (& .ci-python\Scripts\python.exe -c 'import runpy; release = runpy.run_path("scripts/build-python-release.py"); print(release["pep440_version"](release["repository_version"]()))') + - if ($env:CI_COMMIT_TAG -ne "python-v$env:DSH_VERSION") { throw "Tag $env:CI_COMMIT_TAG does not match package.json version $env:DSH_VERSION" } + - .ci-python\Scripts\python.exe -m pip install uv==0.11.23 + script: + - corepack enable + - pnpm install --frozen-lockfile + - pnpm run verify-runtime-closure + - pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=$env:PKG_TARGET + - $exe = Join-Path $PWD "dist-exe\deepseek-harness-sdk-runtime-win-x64.exe" + - if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw "Runtime executable is missing at $exe" } + - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe $exe + - .ci-python\Scripts\python.exe scripts/build-python-release.py --package runtime --tag $env:CI_COMMIT_TAG --platform $env:PLATFORM --runtime-exe $exe --output-dir "release/$env:PLATFORM" + - python -m venv .wheel-smoke + - .wheel-smoke\Scripts\python.exe -m pip install "release/sdk/deepseek_harness_sdk-$env:DSH_WHEEL_VERSION-py3-none-any.whl" "release/win-x64/deepseek_harness_runtime_bin-$env:DSH_WHEEL_VERSION-py3-none-win_amd64.whl" + - Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + - Remove-Item Env:DSH_RUNTIME_MODE -ErrorAction SilentlyContinue + - $blackbox = Join-Path $env:TEMP "dsh-sdk-blackbox-$([guid]::NewGuid())" + - New-Item -ItemType Directory -Path $blackbox | Out-Null + - Push-Location $blackbox + - try { & "$env:CI_PROJECT_DIR\.wheel-smoke\Scripts\python.exe" "$env:CI_PROJECT_DIR\scripts\smoke-python-runtime.py" --scenario all --installed-wheel } finally { Pop-Location } + artifacts: + paths: [release/win-x64/*.whl] + expire_in: 1 week + publish-python: stage: publish tags: [linux-x64] @@ -110,6 +146,8 @@ publish-python: artifacts: true - job: runtime-macos-arm64 artifacts: true + - job: runtime-windows-x64 + artifacts: true before_script: - python3 -m venv .ci-python - . .ci-python/bin/activate @@ -118,11 +156,12 @@ publish-python: - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } - python -m pip install twine==6.2.0 script: - - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 + - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 5 - test -f "release/sdk/deepseek_harness_sdk-${DSH_WHEEL_VERSION}-py3-none-any.whl" - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-manylinux_2_28_x86_64.whl" - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-manylinux_2_28_aarch64.whl" - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-macosx_14_0_arm64.whl" + - test -f "release/win-x64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-win_amd64.whl" - python -m twine check release/*/*.whl - export TWINE_USERNAME=gitlab-ci-token - export TWINE_PASSWORD="$CI_JOB_TOKEN" diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 56334523aa..dcaa5e19a2 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -228,7 +228,7 @@ describe('CI workflow', () => { name: 'python runtime / release-shaped matrix', uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { - targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64', ci: true, }, secrets: { @@ -320,7 +320,7 @@ describe('Python release workflows', () => { expect(build).toMatchObject({ uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { - targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64', release: true, }, }) @@ -390,10 +390,11 @@ describe('Python release workflows', () => { const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28') const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS deployment target') const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container') + const cleanVenv = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv') const installedKeyless = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests') const realApiPreflight = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test') const installedRealApi = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test') - if (!isRecord(installedKeyless) || !isRecord(realApiPreflight) || !isRecord(installedRealApi)) { + if (!isRecord(cleanVenv) || !isRecord(installedKeyless) || !isRecord(realApiPreflight) || !isRecord(installedRealApi)) { throw new TypeError('Python wheel builder must define installed-wheel keyless and real API steps') } expect(call.inputs).toHaveProperty('targets') @@ -407,11 +408,15 @@ describe('Python release workflows', () => { expect(workflow.concurrency).toMatchObject({ group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}', }) + expect(build.defaults).toMatchObject({ run: { shell: 'bash' } }) expect(plan.if).toContain('inputs.ci') expect(plan.if).toContain('inputs.release') expect(JSON.stringify(plan.steps)).toContain('pep440_version') const workflowJson = JSON.stringify(workflow) expect(workflowJson).toContain('macosx_14_0_arm64') + expect(workflowJson).toContain('win_amd64') + expect(workflowJson).toContain('node24-win-x64') + expect(workflowJson).toContain('windows-2025') expect(workflowJson).toContain('dist-python/$SDK_WHEEL') expect(workflowJson).toContain('dist-python/$RUNTIME_WHEEL') expect(workflowJson).toContain('/work/dist-python/$SDK_WHEEL') @@ -422,7 +427,8 @@ describe('Python release workflows', () => { expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') expect(JSON.stringify(manylinuxAddon)).toContain('npm_config_build_from_source=true pnpm run install') - expect(JSON.stringify(manylinuxAddon)).toContain('$HOME/setup-pnpm:$HOME/setup-pnpm:ro') + expect(JSON.stringify(manylinuxAddon)).toContain('pnpm_setup_root') + expect(JSON.stringify(manylinuxAddon)).toContain('$pnpm_setup_root:$pnpm_setup_root:ro') expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt') expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28') expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" }) @@ -432,6 +438,7 @@ describe('Python release workflows', () => { expect(JSON.stringify(installedKeyless)).toContain('--installed-wheel') expect(JSON.stringify(installedKeyless)).toContain('env -u PYTHONPATH') expect(JSON.stringify(installedKeyless)).toContain('-u DSH_RUNTIME_MODE') + expect(JSON.stringify(cleanVenv)).toContain('Scripts/python.exe') expect(realApiPreflight).toMatchObject({ env: { DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}' }, }) @@ -469,6 +476,21 @@ describe('Python release workflows', () => { expect(macosCheck).toContain('scripts/check-macos-deployment-target.py') expect(macosCheck).toContain('"$EXE" "$EXE-spawn-helper"') }) + + it('builds and black-box tests the Windows x64 wheel in GitLab', () => { + const workflow = loadWorkflow('.gitlab-ci.yml') + const windows = workflow['runtime-windows-x64'] + const publish = workflow['publish-python'] + if (!isRecord(windows) || !Array.isArray(windows.script) || !isRecord(publish) || !Array.isArray(publish.needs)) { + throw new TypeError('GitLab CI must define the Windows runtime and aggregate publication jobs') + } + + expect(windows.tags).toEqual(['windows-x64']) + expect(windows.variables).toMatchObject({ PKG_TARGET: 'node24-win-x64', PLATFORM: 'win-x64' }) + expect(JSON.stringify(windows.script)).toContain('win_amd64.whl') + expect(JSON.stringify(windows.script)).toContain('--scenario all --installed-wheel') + expect(publish.needs).toContainEqual({ job: 'runtime-windows-x64', artifacts: true }) + }) }) describe('Issue lifecycle workflow', () => { diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 4a5a3eb0be..b28e740495 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -30,7 +30,7 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" -MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." +MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent shell and string-replacement editor." MINIMAL_TEXT = "minimal agent smoke ok" MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." @@ -41,11 +41,20 @@ MCP_TEXT = "MCP client smoke ok" PROFILE_PLUGIN_PROMPT = "Verify the Python-installed dsh profile plugin." PROFILE_PLUGIN_TEXT = "profile plugin smoke ok" PROFILE_PLUGIN_MARKER = "PYTHON_INSTALLED_DSH_PROFILE_PLUGIN" -MINIMAL_BASH_COMMAND = ( - "counter=$(( ${counter:-0} + 1 )); export counter; " - "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " - "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" +IS_WINDOWS = sys.platform == "win32" +MINIMAL_SHELL_TOOL = "pwsh" if IS_WINDOWS else "bash" +MINIMAL_SHELL_COMMAND = ( + "$global:dshSdkCounter = [int]$global:dshSdkCounter + 1; " + 'Write-Output "COUNT=$global:dshSdkCounter CWD=$((Get-Location).Path)"; ' + "if ($global:dshSdkCounter -eq 1) { Set-Location $env:TEMP }" + if IS_WINDOWS + else ( + "counter=$(( ${counter:-0} + 1 )); export counter; " + "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " + "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" + ) ) +MINIMAL_SHELL_SECOND_CWD = str(Path(tempfile.gettempdir()).resolve()) if IS_WINDOWS else "/tmp" LEGACY_CUSTOM_DISABLED_ROWS = ( "agent-instructions", "goal", @@ -108,6 +117,8 @@ ADVANCED_SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl" MINIMAL_SNAPSHOT_DIRECTORY = ( Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal" ) +if IS_WINDOWS: + MINIMAL_SNAPSHOT_DIRECTORY /= "win-x64" MINIMAL_SNAPSHOT_FILENAMES = ("model-visible.json",) RESTART_SNAPSHOT_DIRECTORY = ( Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart" @@ -301,8 +312,8 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if minimal_prompt is not None: return tool_call_chunks( "minimal-bash-1", - "bash", - {"command": MINIMAL_BASH_COMMAND}, + MINIMAL_SHELL_TOOL, + {"command": MINIMAL_SHELL_COMMAND}, ) scenario_prompts = { SNAPSHOT_DIRECT_CHILD_PROMPT, @@ -438,17 +449,18 @@ def minimal_tool_followup( """Verify the checked-in minimal composition's PTY and editor.""" if not call_id.startswith("minimal-"): return None - if call_id == "minimal-bash-1" and tool_name == "bash": + if call_id == "minimal-bash-1" and tool_name == MINIMAL_SHELL_TOOL: if "COUNT=1" not in tool_text: - raise AssertionError(f"first persistent bash call lost its output: {tool_text}") + raise AssertionError(f"first persistent shell call lost its output: {tool_text}") return tool_call_chunks( "minimal-bash-2", - "bash", - {"command": MINIMAL_BASH_COMMAND}, + MINIMAL_SHELL_TOOL, + {"command": MINIMAL_SHELL_COMMAND}, ) - if call_id == "minimal-bash-2" and tool_name == "bash": - if "COUNT=2 CWD=/tmp" not in tool_text: - raise AssertionError(f"persistent bash did not retain state: {tool_text}") + if call_id == "minimal-bash-2" and tool_name == MINIMAL_SHELL_TOOL: + expected = f"COUNT=2 CWD={MINIMAL_SHELL_SECOND_CWD}" + if expected.lower() not in tool_text.lower(): + raise AssertionError(f"persistent shell did not retain state: {tool_text}") messages = body.get("messages") if not isinstance(messages, list): raise AssertionError("persistent editor smoke request has no messages") @@ -800,8 +812,9 @@ def smoke_sdk_live() -> None: sessions = dsh_home / "sessions" marker = root / "live-api-marker.txt" session_id = "installed-wheel-live-api" + shell_tool = "pwsh" if IS_WINDOWS else "bash" create_prompt = ( - "Use the bash tool to create the file at the absolute path below with exactly one line " + f"Use the {shell_tool} tool to create the file at the absolute path below with exactly one line " f"containing {LIVE_API_SENTINEL}. Then reply with exactly {LIVE_API_SENTINEL}.\n{marker}" ) verify_prompt = ( @@ -845,8 +858,8 @@ def smoke_sdk_live() -> None: raise AssertionError(f"{label} turn returned {result.final_response!r}") if not marker.is_file(): raise AssertionError(f"real-model tool turn did not create {marker}") - if marker.read_bytes() != f"{LIVE_API_SENTINEL}\n".encode(): - raise AssertionError(f"real-model tool turn wrote unexpected bytes to {marker}") + if marker.read_text(encoding="utf-8").splitlines() != [LIVE_API_SENTINEL]: + raise AssertionError(f"real-model tool turn wrote unexpected text to {marker}") assert_zstd_session_log(sessions) @@ -921,6 +934,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: {"id": "session-log-deepseek", "config": {"enabled": True}}, *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), {"id": "tool-bash", "disabled": True}, + {"id": "tool-pwsh", "disabled": True}, { "id": "tool-subagent", "config": { @@ -989,7 +1003,7 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - raise AssertionError(f"minimal agent run emitted no final response: {result.events}") if editor_path.read_text() != "created by packaged editor\n": raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") - assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2") files = build_minimal_snapshot_files(MockModelHandler.requests[first_request:], root) compare_snapshot_files( @@ -1105,7 +1119,7 @@ def smoke_sdk_profile_plugin(base_url: str) -> None: "insert": [{"id": "python-sdk-blackbox-plugin", "name": "dsh-python-blackbox-plugin"}], }], indent=2)) - dsh = Path(sysconfig.get_path("scripts")) / "dsh" + dsh = Path(sysconfig.get_path("scripts")) / ("dsh.exe" if IS_WINDOWS else "dsh") environment = {**os.environ, "DSH_HOME": str(dsh_home)} installed = subprocess.run( [str(dsh), "plugin", "--profile", "sdk", "add", f"file:{plugin}"], @@ -1170,6 +1184,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) {"id": "session-log-deepseek", "config": {"enabled": True}}, *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), {"id": "tool-bash", "disabled": True}, + {"id": "tool-pwsh", "disabled": True}, { "id": "tool-subagent", "config": { @@ -1243,6 +1258,7 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots {"id": "session-log-deepseek", "config": {"enabled": True}}, *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), {"id": "tool-bash", "disabled": True}, + {"id": "tool-pwsh", "disabled": True}, { "id": "tool-subagent", "config": { @@ -1758,7 +1774,7 @@ def compare_snapshot_files( if update: directory.mkdir(parents=True, exist_ok=True) for name, content in files.items(): - (directory / name).write_text(content, encoding="utf-8") + (directory / name).write_text(content, encoding="utf-8", newline="\n") print(f"smoke-python-runtime: updated snapshots in {directory}") existing = { diff --git a/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json b/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json index a3223c8d76..86fcecb5b1 100644 --- a/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json +++ b/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json @@ -81,7 +81,7 @@ }, { "role": "user", - "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt" } ] }, @@ -167,7 +167,7 @@ }, { "role": "user", - "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt" }, { "role": "assistant", @@ -267,7 +267,7 @@ }, { "role": "user", - "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt" }, { "role": "assistant", @@ -381,7 +381,7 @@ }, { "role": "user", - "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt" }, { "role": "assistant", diff --git a/scripts/snapshots/python-sdk-single-exe/minimal/win-x64/model-visible.json b/scripts/snapshots/python-sdk-single-exe/minimal/win-x64/model-visible.json new file mode 100644 index 0000000000..d630a7bf10 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/minimal/win-x64/model-visible.json @@ -0,0 +1,430 @@ +[ + { + "tools": [ + { + "type": "function", + "function": { + "name": "pwsh", + "description": "Run commands in a PowerShell shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* State is persistent across command calls and discussions with the user.\n* Use native Windows paths (C:\\...) and $env:NAME variables; this is PowerShell, not bash.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}\\created.txt" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "pwsh", + "description": "Run commands in a PowerShell shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* State is persistent across command calls and discussions with the user.\n* Use native Windows paths (C:\\...) and $env:NAME variables; this is PowerShell, not bash.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}\\created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "pwsh", + "description": "Run commands in a PowerShell shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* State is persistent across command calls and discussions with the user.\n* Use native Windows paths (C:\\...) and $env:NAME variables; this is PowerShell, not bash.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}\\created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-2", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-2", + "text": "{{tool-result}}" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "pwsh", + "description": "Run commands in a PowerShell shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* State is persistent across command calls and discussions with the user.\n* Use native Windows paths (C:\\...) and $env:NAME variables; this is PowerShell, not bash.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}\\created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-2", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-2", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-editor", + "name": "str_replace_editor" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-editor", + "text": "{{tool-result}}" + } + ] + } +] From 28442337cfa387ed2a2f92a8b7551026c49db23e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:10:58 +0800 Subject: [PATCH 079/138] feat(python-example): select the persistent shell by platform Make the checked-in minimal SDK overlay disable both one-shot shell rows and mount exactly one persistent PTY stack: Bash on Linux/macOS and PowerShell on Windows. The SDK server, explicit dsh home, persistence, editor, timeout, and reduced tool catalog remain unchanged. Update the runnable example and tutorial to list Windows x64 as supported, describe the platform-selected shell, and remove the obsolete POSIX-only restriction. This keeps the documented first Python task executable through the packaged Windows dsh profile instead of advertising a Linux-only overlay on a Windows-capable SDK. --- ...4-standalone-sdk-minimal-profile.i18n.yaml | 4 ++-- ...26-08-24-standalone-sdk-minimal-profile.md | 4 ++-- ...08-24-standalone-sdk-minimal-profile.zh.md | 4 ++-- ...nimal-preset-owns-rl-composition.i18n.yaml | 4 ++-- ...8-10-minimal-preset-owns-rl-composition.md | 2 +- ...0-minimal-preset-owns-rl-composition.zh.md | 2 +- ...l-profiles-bare-two-tool-runtime.i18n.yaml | 4 ++-- ...-minimal-profiles-bare-two-tool-runtime.md | 6 ++--- ...nimal-profiles-bare-two-tool-runtime.zh.md | 6 ++--- apps/cli/tests/built-bin.e2e.ts | 2 ++ docs/user/guide/python-sdk.i18n.yaml | 4 ++-- docs/user/guide/python-sdk.md | 8 +++---- docs/user/guide/python-sdk.zh.md | 8 +++---- examples/python-sdk-agent/README.i18n.yaml | 4 ++-- examples/python-sdk-agent/README.md | 4 ++-- examples/python-sdk-agent/README.zh.md | 4 ++-- .../tests/keyless-smoke.e2e.ts | 3 ++- packages/bundle/sdk-minimal/README.i18n.yaml | 4 ++-- packages/bundle/sdk-minimal/README.md | 7 +++--- packages/bundle/sdk-minimal/README.zh.md | 7 +++--- packages/bundle/sdk-minimal/cordis.patch.yml | 23 +++++++++++++++++++ packages/bundle/sdk-minimal/package.json | 1 + .../sdk-minimal/tests/sdk-minimal.spec.ts | 11 ++++++++- pnpm-lock.yaml | 3 +++ 24 files changed, 85 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml index 4a06faa161..0d7ca581c3 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.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-24-standalone-sdk-minimal-profile.md -2026-08-24-standalone-sdk-minimal-profile.md: bc1a177dc4a232201004e6869caa452a7d55deb9 -2026-08-24-standalone-sdk-minimal-profile.zh.md: ae6fdb95079f748f26758a30c968c27548e9a87f +2026-08-24-standalone-sdk-minimal-profile.md: 692bf9763f4ad710ff5cc819a7480b2f3e8d9b5f +2026-08-24-standalone-sdk-minimal-profile.zh.md: f39baad1b4429b73f13d601716dadd8376c71bfb diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md index bc1a177dc4..692bf9763f 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -22,9 +22,9 @@ The bundle reuses `@deepseek-ai/dsh-sdk-app` for command help, stdin EOF, and bo ### Explicit composition -The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, persistent Bash, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. The SDK initialization request owns the model id; `DSH_CONTEXT_WINDOW` supplies fallback capacity for models outside the adapter's advisory catalog. The persona comes from `DSH_SYSTEM_PROMPT`, and the credential from `DEEPSEEK_API_KEY`. +The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a platform-selected persistent shell, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. Linux and macOS mount Bash; Windows mounts PowerShell. The SDK initialization request owns the model id; `DSH_CONTEXT_WINDOW` supplies fallback capacity for models outside the adapter's advisory catalog. The persona comes from `DSH_SYSTEM_PROMPT`, and the credential from `DEEPSEEK_API_KEY`. -Harness identity, runtime context, workspace instructions, skills, model-facing job controls, compaction, settings, managed credentials, telemetry, Web tools, subagents, and every other base row are absent rather than hidden. The profile pins `danger-full-access`, `maxTokensAsSuccess: false`, and startup-only patch loading. This layer is POSIX-only because its persistent terminal uses Bash. +Harness identity, runtime context, workspace instructions, skills, model-facing job controls, compaction, settings, managed credentials, telemetry, Web tools, subagents, and every other base row are absent rather than hidden. The profile pins `danger-full-access`, `maxTokensAsSuccess: false`, and startup-only patch loading. ### Customization and Web diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md index ae6fdb9507..f39baad1b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -22,9 +22,9 @@ Status: implemented ### 显式组合 -该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。SDK 初始化请求拥有模型 id;`DSH_CONTEXT_WINDOW` 为不在适配器建议目录中的模型提供后备容量。Persona 来自 `DSH_SYSTEM_PROMPT`,凭据来自 `DEEPSEEK_API_KEY`。 +该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、按平台选择的持久 shell、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。Linux 与 macOS 挂载 Bash,Windows 挂载 PowerShell。SDK 初始化请求拥有模型 id;`DSH_CONTEXT_WINDOW` 为不在适配器建议目录中的模型提供后备容量。Persona 来自 `DSH_SYSTEM_PROMPT`,凭据来自 `DEEPSEEK_API_KEY`。 -Harness 身份、运行时上下文、workspace 指令、skills、面向模型的 job 控制、compaction、settings、托管凭据、遥测、Web 工具、subagent 与其他所有 base 配置项均不存在,而不是被隐藏。该 profile 固定使用 `danger-full-access`、`maxTokensAsSuccess: false` 与仅启动时 patch 加载。由于持久终端使用 Bash,此层只支持 POSIX。 +Harness 身份、运行时上下文、workspace 指令、skills、面向模型的 job 控制、compaction、settings、托管凭据、遥测、Web 工具、subagent 与其他所有 base 配置项均不存在,而不是被隐藏。该 profile 固定使用 `danger-full-access`、`maxTokensAsSuccess: false` 与仅启动时 patch 加载。 ### 自定义与 Web diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml index 49ebbb6567..38476183a5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.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-minimal-preset-owns-rl-composition.md -2026-08-10-minimal-preset-owns-rl-composition.md: 2e9a3e56252f8e91008a5559ad738a7ca678446b -2026-08-10-minimal-preset-owns-rl-composition.zh.md: 31df6ebfbc15f35208bc73b391725b2039fe7819 +2026-08-10-minimal-preset-owns-rl-composition.md: 4c296ed4af5df7a48bdfba6ff1972321cc2e54cf +2026-08-10-minimal-preset-owns-rl-composition.zh.md: 545f0a32fe9c7726d6fc910d0598174e7d7a3ec1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md index 2e9a3e5625..4c296ed4af 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -36,4 +36,4 @@ The standalone [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/RE ## Consequences -The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and `sdk-minimal` profile state the same two-tool behavior for their respective launch paths. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset does not support Windows agents. +The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and `sdk-minimal` profile share persistent-shell-plus-editor behavior for their respective launch paths; `sdk-minimal` selects PowerShell on Windows. Shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The Web preset's Bash backend requires the supported POSIX terminal substrate, so that preset does not support Windows agents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md index 31df6ebfbc..545f0a32fe 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -36,4 +36,4 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,它 ## 后果 -Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与 `sdk-minimal` profile 分别为各自启动路径声明相同的双工具行为。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。 +Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与 `sdk-minimal` profile 在各自启动路径共享持久 shell 加 editor 的行为;`sdk-minimal` 在 Windows 上选择 PowerShell。Shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。Web preset 的 Bash 后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index 4a576e9339..64027860ca 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 6ea86832b632c631b7e02d6c486f3858fd2632a4 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 73f6f16a51c14a7c98915a84878b39596d12f245 +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 6ff0fc360e7187db0e5a93e7edc8b1e48eecdbfe +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 696538381bed54d46c35d6ebe9ba2142adce9d79 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index 6ea86832b6..6ff0fc360e 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -12,9 +12,9 @@ The two launch paths also have different configuration owners. Web mounts a per- ## Decision -Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. +The shipped Web minimal preset exposes persistent `bash` and `str_replace_editor`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows, plus the same editor. Both mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. -The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. The [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns this bundle placement and its separation from `dsh-base`. +The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, `fs-local`, that shell's tool consumer, the editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. The persistent shell consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. The [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns this bundle placement and its separation from `dsh-base`. `DSH_SYSTEM_PROMPT` selects the standalone persona, and `DSH_CONTEXT_WINDOW` supplies fallback capacity for a model without exact catalog metadata. The SDK client's JSON-RPC `initialize` request is the sole runtime model selection. [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) may read `DSH_MODEL` only as the command's default `model` argument; an explicit `--model` needs no matching child environment value. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. @@ -22,7 +22,7 @@ The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bund The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services. -The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes both tools. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. +The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index 73f6f16a51..696538381b 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -12,9 +12,9 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` ## 决策 -两种随附 minimal profile 都只对外提供持久 `bash` 与 `str_replace_editor`,不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 +随附 Web minimal preset 对外提供持久 `bash` 与 `str_replace_editor`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`,并提供相同 editor。两者都不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 -独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责该组合包的位置及其与 `dsh-base` 的分离。 +独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、`fs-local`、该 shell 的工具消费方、editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 shell 消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责该组合包的位置及其与 `dsh-base` 的分离。 `DSH_SYSTEM_PROMPT` 选择独立组合的 persona,`DSH_CONTEXT_WINDOW` 为没有确切目录元数据的模型提供后备容量。SDK 客户端的 JSON-RPC `initialize` 请求是唯一运行时模型选择。[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)可以只把 `DSH_MODEL` 读作命令的默认 `model` 参数;显式 `--model` 不需要匹配的子进程环境值。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 @@ -22,7 +22,7 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。 -SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行两个工具。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 +SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 ## 考虑过的替代方案 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 397e960075..eb22fed232 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -946,9 +946,11 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['subprocess', '@deepseek-ai/dsh-subprocess-local'], ['pty', '@deepseek-ai/dsh-terminal'], ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], ['fs-local', '@deepseek-ai/dsh-fs-local'], ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], ]) diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 4ddf1a6015..10a8c18c63 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/python-sdk.md -python-sdk.md: 24f5594a20eab6870d9725e0f1acfce5dff62f74 -python-sdk.zh.md: 47b420ab20df04d28e5498807dc73a425c7a9666 +python-sdk.md: 5fd8b35c08acdd0f0ff457547ca62b31e12994d5 +python-sdk.zh.md: 354d6829dc07056556d19ddfca68a95ad3a5b47f diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 24f5594a20..5fd8b35c08 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -8,7 +8,7 @@ This tutorial installs the published Python SDK, runs the shipped standalone min - Python 3.10 or newer - Git -- Linux x64, Linux arm64, or macOS 14 or newer on arm64 +- Linux x64, Linux arm64, macOS 14 or newer on arm64, or Windows x64 - A DeepSeek-compatible API endpoint and credential - An isolated workspace and an isolated Harness home @@ -92,13 +92,13 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe |---|---| | System prompt | `DSH_SYSTEM_PROMPT`, falling back to `You are a helpful software engineer assistant.` | | Model in `minimal.py` | `--model`, then `DSH_MODEL`, then `deepseek-v4-flash` | -| Model-facing tools | Persistent `bash` and `str_replace_editor` only | -| Bash timeout | 300 seconds | +| Model-facing tools | Persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor` | +| Shell timeout | 300 seconds | | Editor output limit | 16,000 characters | | Runtime context and compaction | Absent | | Session persistence | Uncompressed JSONL under `/sessions` | -The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so persistent Bash and the editor can modify any path visible to the runtime; use a disposable checkout or container. The PTY implementation makes this example POSIX-only. +The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so the platform-selected persistent shell and editor can modify any path visible to the runtime; use a disposable checkout or container. The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 47b420ab20..354d6829dc 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -8,7 +8,7 @@ - Python 3.10 或更高版本 - Git -- Linux x64、Linux arm64,或 arm64 上的 macOS 14 或更高版本 +- Linux x64、Linux arm64、arm64 上的 macOS 14 或更高版本,或 Windows x64 - DeepSeek 兼容的 API endpoint 与凭据 - 隔离的 workspace 与隔离的 Harness home @@ -92,13 +92,13 @@ dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle |---|---| | 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` | | `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` | -| 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | -| Bash 超时 | 300 秒 | +| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh`,以及 `str_replace_editor` | +| Shell 超时 | 300 秒 | | Editor 输出上限 | 16,000 字符 | | 运行时上下文与 compaction | 不存在 | | 会话持久化 | `/sessions` 下的未压缩 JSONL | -该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此持久 Bash 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 +该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index e57c422380..07b10f117e 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-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 examples/python-sdk-agent/README.md -README.md: 46a8dc2384d96db39841c4c1e4cdc88d82ff55eb -README.zh.md: 5e6e2f89f27398dd7404894f15391a6a693d689b +README.md: 7ec0ce984d20205fa50a3f12753a94f4209fac35 +README.zh.md: e46269f7d6a37123098747d2c8112aa365a98f01 diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index 46a8dc2384..7ec0ce984d 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -21,12 +21,12 @@ Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the script's def The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../packages/bundle/sdk-minimal/README.md) is the complete explicit Cordis tree for this mode. It exposes exactly: -- owner-scoped persistent `bash` +- owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, local instruction discovery, compaction, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. -This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. +The persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. ## Add plugins diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index 5e6e2f89f2..e46269f7d6 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -21,12 +21,12 @@ python examples/python-sdk-agent/minimal.py \ 随附的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../packages/bundle/sdk-minimal/README.zh.md)是该模式完整且显式的 Cordis 配置树。它只暴露: -- agent 所有的持久 `bash` +- Linux/macOS 上 agent 所有的持久 `bash`,或 Windows 上的 `pwsh` - 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` 该组合包不包含 `dsh-base`,因此每一个新增配置项都是显式 profile 变更。运行时上下文、本地指令发现、compaction、settings、托管凭据、遥测、Web 工具、subagent 与完整默认工具清单均不存在。配置树保留 SDK 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、本地执行和 JSONL 持久化。 -此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 +持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 ## 添加插件 diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 230ada0336..d2e90a9de3 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -242,7 +242,8 @@ describe('Python SDK dsh profile keyless smoke', () => { tools?: Array<{ function?: { name?: string } }> } expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) - expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual(['bash', 'str_replace_editor']) + const shellTool = process.platform === 'win32' ? 'pwsh' : 'bash' + expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual([shellTool, 'str_replace_editor'].sort()) const profile = JSON.parse( await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'), ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } } diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index c573869472..0c606565e9 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md -README.md: b33ccb429ab0291d46f0271329b957f0ac7011fa -README.zh.md: 9e4ab381f5595630cbf139b1a1e8e30cef147f39 +README.md: 3c8d4efa7540e8ff317c897f2e2f463e448610bb +README.zh.md: 54a9d99c95322a34433aec81cb6d37cb16e5015e diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index b33ccb429a..3c8d4efa75 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -2,19 +2,21 @@ English | [中文](README.zh.md) -Standalone minimal SDK application bundle for `dsh --profile sdk-minimal`. Its single insert is the complete Cordis tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a persistent Bash PTY, the string-replace editor, and uncompressed JSONL session persistence under `$DSH_HOME/sessions`. It deliberately does not include [`dsh-base`](../base/README.md), Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs tools, subagents, or any other model-facing tool. +Standalone minimal SDK application bundle for `dsh --profile sdk-minimal`. Its single insert is the complete Cordis tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a platform-selected persistent shell PTY, the string-replace editor, and uncompressed JSONL session persistence under `$DSH_HOME/sessions`. It deliberately does not include [`dsh-base`](../base/README.md), Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs tools, subagents, or any other model-facing tool. The profile remains part of the ordinary launcher and layering model. The bundle supplies the complete default tree; the profile patch, home patch, and ordered `--patch` files can replace rows or insert external bundles above it. `dsh plugin --profile sdk-minimal` manages persistent dependencies. The shipped template uses startup-only patches so one stdio connection never observes replacement of its server or agent dependencies. `DEEPSEEK_API_KEY` supplies the adapter credential. The SDK initialization request is the sole model selection; the adapter accepts that model id even when it is absent from its advisory catalog. `DSH_CONTEXT_WINDOW` sets the fallback capacity for such models, and `DSH_SYSTEM_PROMPT` replaces the default persona. The process working directory is the sandbox-policy workspace and local-filesystem root. The bundle sets `danger-full-access`; its persistent shell and editor can modify any path available to the process. +Exactly one persistent shell stack mounts by platform: Bash on Linux/macOS or PowerShell on Windows. Both use a 300-second timeout and one owner-scoped terminal; the other platform rows remain disabled. + ## Model Experience ### Minimal coding-agent composition #### What the model sees -The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` and `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. #### Token effect @@ -26,6 +28,5 @@ Stable for a fixed persona, platform, provider, model, and bundle patch stack. P ## Known Limitations and Deferred Work -- **The profile is POSIX-only** — this composition uses a Bash PTY; a Windows profile must select a PowerShell terminal and tool instead. - **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. - **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index 9e4ab381f5..54a9d99c95 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -2,19 +2,21 @@ [English](README.md) | 中文 -供 `dsh --profile sdk-minimal` 使用的独立极简 SDK 应用组合包。它的单个 insert 构成完整 Cordis 树:SDK stdio 启动与 JSON-RPC 对外服务、一个由环境配置的 DeepSeek 适配器、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话持久化。它刻意不包含 [`dsh-base`](../base/README.zh.md)、Web、settings、托管凭据、遥测、压缩(compaction)、workspace 指令、skills、jobs 工具、subagent 或任何其他面向模型的工具。 +供 `dsh --profile sdk-minimal` 使用的独立极简 SDK 应用组合包。它的单个 insert 构成完整 Cordis 树:SDK stdio 启动与 JSON-RPC 对外服务、一个由环境配置的 DeepSeek 适配器、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、按平台选择的持久 shell PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话持久化。它刻意不包含 [`dsh-base`](../base/README.zh.md)、Web、settings、托管凭据、遥测、压缩(compaction)、workspace 指令、skills、jobs 工具、subagent 或任何其他面向模型的工具。 该 profile 仍遵循普通 launcher 与分层模型。组合包提供完整默认树;profile patch、home patch 与有序 `--patch` 文件可以在其上替换配置项或插入外部组合包。`dsh plugin --profile sdk-minimal` 管理持久依赖。随附模板仅在启动时应用 patch,因此一个 stdio 连接不会观察到服务器或 agent 依赖在运行中被替换。 `DEEPSEEK_API_KEY` 提供适配器凭据。SDK 初始化请求是唯一模型选择;即使该模型 id 不在适配器的建议目录中,适配器也会接受它。`DSH_CONTEXT_WINDOW` 为这类模型设置后备容量,`DSH_SYSTEM_PROMPT` 替换默认 persona。进程工作目录同时作为沙箱策略 workspace 与本地文件系统根目录。该组合包设置 `danger-full-access`;其持久 shell 与编辑器可以修改进程可访问的任何路径。 +运行时会按平台恰好挂载一套持久 shell:Linux/macOS 使用 Bash,Windows 使用 PowerShell。两者都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。 + ## 模型体验 ### 极简 coding agent 组合 #### 模型看到的内容 -系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 agent 所有的持久 `bash` 与 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`,外加 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 #### Token 影响 @@ -26,6 +28,5 @@ ## 已知限制与待办工作 -- **该 profile 仅支持 POSIX** — 此组合使用 Bash PTY;Windows profile 必须改为选择 PowerShell 终端与工具。 - **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、权限策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 - **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index 24d707c7e9..361e9a6f53 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -47,9 +47,17 @@ - id: terminal-bash name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 + - id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + timeoutMs: 300000 + # The editor uses the bare local filesystem; persistent Bash still consumes # the shared danger-full-access sandbox policy above. - id: fs-local @@ -71,6 +79,7 @@ - id: persistent-bash name: '@deepseek-ai/dsh-tool-bash-persistent' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 description: |- @@ -83,6 +92,20 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + - id: persistent-pwsh + name: '@deepseek-ai/dsh-tool-pwsh-persistent' + disabled: !!js process.platform !== 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a PowerShell shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * State is persistent across command calls and discussions with the user. + * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. + - id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 1b3d5da5d2..42940a0ac8 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^" }, "peerDependencies": { diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index f8c23e92ac..7983a7793c 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -18,7 +18,7 @@ describe('dsh-sdk-minimal bundle', () => { const patches = yaml.load( readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema }, - ) as Array<{ insert?: Array<{ id?: string; inject?: string[]; name?: string; config?: Record }> }> + ) as Array<{ insert?: Array<{ id?: string; inject?: string[]; name?: string; config?: Record; disabled?: unknown }> }> expect(patches).toHaveLength(1) const rows = patches[0]?.insert ?? [] expect(rows.map(row => [row.id, row.name])).toEqual([ @@ -33,9 +33,11 @@ describe('dsh-sdk-minimal bundle', () => { ['subprocess', '@deepseek-ai/dsh-subprocess-local'], ['pty', '@deepseek-ai/dsh-terminal'], ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], ['fs-local', '@deepseek-ai/dsh-fs-local'], ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], ]) @@ -57,6 +59,13 @@ describe('dsh-sdk-minimal bundle', () => { toolBash: false, toolJobs: false, }) + expect(rows.find(row => row.id === 'terminal-bash')).toMatchObject({ + disabled: { __jsExpr: "process.platform === 'win32'" }, + }) + expect(rows.find(row => row.id === 'terminal-pwsh')).toMatchObject({ + disabled: { __jsExpr: "process.platform !== 'win32'" }, + config: { shellDialect: 'pwsh', timeoutMs: 300000 }, + }) expect(Object.keys(manifest.dependencies ?? {}).sort()).toEqual( [...new Set(rows.map(row => row.name).filter((name): name is string => name !== undefined))].sort(), ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7f70486da..c13799de2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1591,6 +1591,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-str-replace-editor': specifier: workspace:^ version: link:../../fs/tool-str-replace-editor From d4a63abe859a39bf198cb402f8001c41ec92fbba Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:11:19 +0800 Subject: [PATCH 080/138] docs(python): define the Windows x64 runtime contract Record win-x64 as the sole Windows Python carrier: node24-win-x64 builds a py3-none-win_amd64 wheel with dsh.exe, rg.exe, and both ConPTY addons; Windows arm64 remains explicitly unsupported. The note also pins native build ownership, shell-free pnpm launch, installed-wheel keyless/live gates, and the PowerShell-specific minimal snapshot. Update the active SEA, sole-launcher, profile-runtime, installed-wheel, and publication decisions from three runtime wheels to four, preserving their existing rationale while linking the Windows extension. Contributor and runtime references now state the exact target, filenames, sidecars, snapshot ownership, and five-wheel release set in both languages. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- ...-single-dsh-application-launcher.i18n.yaml | 4 +- ...6-08-22-single-dsh-application-launcher.md | 6 +-- ...8-22-single-dsh-application-launcher.zh.md | 6 +-- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- ...3-python-sdk-windows-x64-runtime.i18n.yaml | 6 +++ ...26-08-23-python-sdk-windows-x64-runtime.md | 49 +++++++++++++++++++ ...08-23-python-sdk-windows-x64-runtime.zh.md | 49 +++++++++++++++++++ ...8-11-python-publication-workflow.i18n.yaml | 4 +- .../2026-08-11-python-publication-workflow.md | 6 +-- ...26-08-11-python-publication-workflow.zh.md | 6 +-- ...talled-python-wheel-black-box-ci.i18n.yaml | 4 +- ...-23-installed-python-wheel-black-box-ci.md | 8 +-- ...-installed-python-wheel-black-box-ci.zh.md | 8 +-- python/development.i18n.yaml | 4 +- python/development.md | 8 +-- python/development.zh.md | 8 +-- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 4 +- python/sdk-runtime/README.zh.md | 4 +- 24 files changed, 156 insertions(+), 52 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 64c9153ca6..f03f7e81ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: e46dbbc119e2078e44632d81b333c8be5ab9d6d7 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7a1e3f445ea1c1f03df2b349a4391534a5502c3 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 8731528b9ae600bb8bfe12738669f3a84c11a06b +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 755b4bd7ddbe9b88b4f40b8a3ae7419f746b8dde diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index e46dbbc119..8731528b9a 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -44,13 +44,13 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all three targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index d7a1e3f445..755b4bd7dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -44,13 +44,13 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部三个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签,或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 61028c878a..f92d9862cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 48a45cb2454b5532a78474203b6d88aef3dd0697 -2026-08-22-single-dsh-application-launcher.zh.md: dbf2fb3a5bdc16208b0435482d8d0851bd7c44d7 +2026-08-22-single-dsh-application-launcher.md: 4640173068998d518f5cbdf537526ea479242b50 +2026-08-22-single-dsh-application-launcher.zh.md: 4f42652497b84a431a88442fe812045550bcb4ff diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 48a45cb245..4640173068 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -8,7 +8,7 @@ English | [中文](2026-08-22-single-dsh-application-launcher.zh.md) DeepSeek Harness application processes need one owner for composition, plugin resolution, environment discovery, shutdown, and user customization. A dedicated app bin with a complete `cordis.yml` creates a second lifecycle beside profile launch: plugins installed into a profile do not reach it, behavior drifts from `dsh-base`, and SDK callers learn arbitrary process argv instead of the product's composition model. -The Python SDK distributes a native executable and three platform wheels. Its packaged process must use the same profile launcher while preserving the closed VFS dependency tree, native sidecars, and installed-wheel evidence. +The Python SDK distributes a native executable through four platform wheels. Its packaged process uses the same profile launcher while preserving the closed VFS dependency tree, native sidecars, and installed-wheel evidence. ## Decision @@ -48,7 +48,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable Python example selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. -The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. +The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this launch, and the [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth carrier. ### Enforcement @@ -76,7 +76,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o **Hot-reload protocol profiles.** Rejected: replacing a protocol server or its dependencies can invalidate pending frames and SDK-owned agents. Process restart is the adoption boundary for SDK and ACP configuration changes. -**Move the Python executable through profiles without a separate packaging proof.** Rejected: the native VFS closure, three platform wheels, ripgrep and spawn-helper sidecars, default config discovery, and clean-install behavior require their own migration evidence. +**Move the Python executable through profiles without a separate packaging proof.** Rejected: the native VFS closure, four platform wheels, profile assets, ripgrep and spawn-helper sidecars, and clean-install behavior require their own migration evidence. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index dbf2fb3a5b..4f42652497 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -8,7 +8,7 @@ Status: implemented DeepSeek Harness 应用进程需要由同一个机制负责组合、插件解析、环境发现、关闭和用户自定义。带完整 `cordis.yml` 的专用应用 bin 会在 profile 启动之外形成第二套生命周期:安装到 profile 的插件无法到达它,行为会与 `dsh-base` 偏离,SDK 调用方还需要学习任意进程 argv,而不是产品的组合模型。 -Python SDK 分发一个原生可执行文件和三个平台 wheel 包。其打包进程必须使用同一 profile 启动器,同时保留封闭的 VFS 依赖树、原生伴随文件与 installed-wheel 证据。 +Python SDK 通过四个平台 wheel 包分发原生可执行文件。其打包进程使用同一 profile 启动器,同时保留封闭的 VFS 依赖树、原生伴随文件与 installed-wheel 证据。 ## Decision @@ -48,7 +48,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;可运行 Python 示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 -可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 +可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该启动方式,[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个载体。 ### 强制校验 @@ -76,7 +76,7 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest **热重载协议 profile。** 拒绝:替换协议服务器或其依赖可能破坏待处理协议帧与 SDK 自有 agent。进程重启是 SDK 与 ACP 配置变更的采用边界。 -**不做独立打包证明就把 Python 可执行文件迁移到 profile。** 拒绝:原生 VFS 闭包、三个平台 wheel 包、ripgrep 与 spawn-helper 伴随文件、默认配置发现和干净安装行为都需要自己的迁移证据。 +**不做独立打包证明就把 Python 可执行文件迁移到 profile。** 拒绝:原生 VFS 闭包、四个平台 wheel 包、profile 资源、ripgrep 与 spawn-helper 伴随文件和干净安装行为都需要自己的迁移证据。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index f418c62b83..add950844a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 3298224688e8f0cd4216f8ed68d9e4364014d2a9 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 400e70113e1112aa6e4979c64a5046199c07e8a0 +2026-08-23-python-sdk-dsh-profile-runtime.md: dcb4f77048d516f0e187b611dc09c224fba47b58 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 505b49506ae4c4be4809d588448b2c593d3ebaf5 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 3298224688..dcb4f77048 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -34,7 +34,7 @@ The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages ` Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. A complete matching generation returns without acquiring the cross-process writer lock. A missing or stale entry acquires the lock, rechecks the generation, and repairs it without exposing partial proxies; either carrier can replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. -The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. +The published target set is Linux x64, Linux arm64, macOS arm64, and Windows x64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth artifact and its platform-specific shell surface. ## Existing decisions and supersession diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 400e70113e..505b49506a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -34,7 +34,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。完整且匹配的 generation 不会获取跨进程写入锁。缺失或过期的配置项会获取该锁、重新检查 generation,并在不暴露半成品代理的前提下修复;任一载体都可以替换另一载体留下的受管配置项。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 -已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 +已发布目标集合是 Linux x64、Linux arm64、macOS arm64 与 Windows x64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个产物及其平台专属 shell surface。 ## 既有决策与取代关系 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml new file mode 100644 index 0000000000..732ea6c39f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.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-23-python-sdk-windows-x64-runtime.md +2026-08-23-python-sdk-windows-x64-runtime.md: 57c3ac66517d62528464521ba37e9c644899d1ca +2026-08-23-python-sdk-windows-x64-runtime.zh.md: e50efac91bed33f6559386ebdbb3deaa52c9d3ca diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md new file mode 100644 index 0000000000..57c3ac6651 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md @@ -0,0 +1,49 @@ +# Agent Note: Python SDK Windows x64 runtime + +Status: implemented + +English | [中文](2026-08-23-python-sdk-windows-x64-runtime.zh.md) + +## Problem + +The Python SDK runtime distribution needs a Windows carrier without creating another application entrypoint or weakening the installed-wheel evidence used by the existing native targets. Windows executable names, Python wheel tags, ConPTY addons, ripgrep sidecars, shell composition, virtual environments, and process launch rules differ from Linux and macOS. Claiming Windows from cross-platform unit tests or from a non-Windows executable would leave the artifact selected by `pip` unproved. + +## Decision + +### One x64 product + +`python/sdk-runtime/platforms.json` declares one Windows target, `win-x64`. Its pkg target is `node24-win-x64`, its runtime wheel tag is `py3-none-win_amd64`, and its payload is `deepseek-harness-sdk-runtime-win-x64.exe` with `deepseek-harness-sdk-runtime-win-x64-rg.exe`. The packaged `node-pty` tree must contain both x64 ConPTY addons. Runtime lookup rejects Windows arm64 rather than selecting or relabeling the x64 wheel. + +The Python process still launches the ordinary `dsh --profile sdk` application and requires an explicit Harness home under the [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md). Windows adds no Python-specific Node application, complete-config entrypoint, implicit `~/.dsh`, or system Node requirement. + +### Native build and publication + +The executable builder accepts `win` as a pkg platform only with x64, requires the Windows build to run under x64 Node on a Windows host, preserves `.exe` names, and copies `@vscode/ripgrep-win32-x64` as the conventional `-rg.exe` sidecar. Pnpm subprocesses use a caller-supplied JavaScript entry through `process.execPath`. When the caller exposes a `.cmd` shim, the builder resolves the installed `pnpm.mjs` or `pnpm.cjs` through `PNPM_HOME`; it fails if no JavaScript entry exists instead of spawning the shim or enabling a command shell. + +The required GitHub matrix builds `node24-win-x64` on `windows-2025` beside the three existing targets. The public GitHub release and GitLab tag pipeline each publish the same four runtime wheels plus the pure SDK wheel. Windows arm64 is absent from target parsing, manifests, matrices, release contents, and documentation. + +### Installed-wheel behavior + +The Windows lane creates a clean Windows virtual environment, installs the exact SDK and `win_amd64` runtime wheels, changes to a directory outside the checkout, unsets `PYTHONPATH` and `DSH_RUNTIME_MODE`, and runs the same `--scenario all --installed-wheel` blackbox as every other target. Trusted pull requests also run the same two-turn `sdk-live` provider scenario. Fork and Dependabot heads receive no key. + +After a successful shutdown response, the Python client closes stdin and waits within the configured shutdown timeout for the `dsh` context to exit and flush durable session state before terminating it. A failed shutdown retains immediate bounded termination. This distinction preserves the final accepted turn on Windows, where `terminate()` force-kills the process rather than delivering a catchable signal. + +The minimal blackbox uses persistent `pwsh` plus `str_replace_editor` on Windows and owns `minimal/win-x64/model-visible.json`; Linux and macOS retain persistent Bash and the shared `minimal/model-visible.json`. The advanced process/subagent snapshot and restart/durable-log snapshot remain shared across all targets. The shipped [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) selects the same platform shell pair for the runnable Python tutorial. + +## Existing decisions and supersession + +This decision partially supersedes the Windows non-goal in the [single-file runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md) and extends the required target set in the [installed Python wheel blackbox decision](../testing/2026-08-23-installed-python-wheel-black-box-ci.md). Those notes remain authoritative for SEA packaging, the two Python distributions, provenance checks, key handling, and the common blackbox scenarios. + +## Alternatives considered + +**Add Windows before the dsh profile runtime.** Rejected because tests for the retired private direct-config carrier would not prove the Windows form users receive. Windows is defined only for the sole `dsh` launch architecture. + +**Publish Windows arm64 too.** Rejected because the accepted product scope is x64 only; adding a second architecture would require its own native builder, wheel tag, ConPTY and ripgrep payload checks, installed-wheel matrix leg, and release artifact. + +**Give Windows a smaller smoke suite.** Rejected because a platform wheel cannot borrow protocol, persistence, worker, MCP, plugin, native-tool, or real-provider evidence from another executable. Platform-specific expected output is limited to the persistent shell surface; the remaining snapshots stay shared. + +**Run Windows commands through PowerShell workflow steps only.** Rejected for the reusable build body because it would duplicate the Linux/macOS installation and blackbox sequence. Git Bash supplies the common workflow grammar; only virtual-environment executable selection and the product payload names differ. + +## Consequences + +Python installation now selects a Node-free Windows x64 runtime with the same explicit-home and profile customization model as Linux and macOS. Every pull request pays for a fourth executable, runtime wheel, full keyless blackbox, and—on trusted heads—real provider task. Release validation retains five wheels instead of four. Windows arm64 users receive an explicit unsupported-platform failure until a separate native product decision supplies and proves that carrier. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md new file mode 100644 index 0000000000..e50efac91b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Python SDK Windows x64 运行时 + +Status: implemented + +[English](2026-08-23-python-sdk-windows-x64-runtime.md) | 中文 + +## Problem + +Python SDK 运行时分发需要 Windows 载体,同时不能创建另一个应用入口,也不能削弱现有原生目标所使用的 installed-wheel 证据。Windows 的可执行文件名、Python wheel 标签、ConPTY addon、ripgrep sidecar、shell 组合、虚拟环境与进程启动规则均不同于 Linux 和 macOS。仅凭跨平台单元测试或非 Windows 可执行文件声称支持 Windows,会使 `pip` 实际选择的产物未经证明。 + +## Decision + +### 唯一 x64 产品 + +`python/sdk-runtime/platforms.json` 声明唯一的 Windows 目标 `win-x64`。其 pkg 目标是 `node24-win-x64`,运行时 wheel 标签是 `py3-none-win_amd64`,载荷包含 `deepseek-harness-sdk-runtime-win-x64.exe` 与 `deepseek-harness-sdk-runtime-win-x64-rg.exe`。打包后的 `node-pty` 文件树必须包含两个 x64 ConPTY addon。运行时查找会拒绝 Windows arm64,不会选择 x64 wheel 或把它重新标记为 arm64。 + +Python 进程仍按 [Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)启动普通 `dsh --profile sdk` 应用,并要求显式 Harness home。Windows 不会增加 Python 专用 Node 应用、完整配置入口、隐式 `~/.dsh` 或系统 Node 要求。 + +### 原生构建与发布 + +可执行文件构建器仅允许 x64 使用 pkg 的 `win` 平台,并要求 Windows 构建在 Windows 宿主的 x64 Node 下运行;构建器保留 `.exe` 文件名,并把 `@vscode/ripgrep-win32-x64` 复制为常规 `-rg.exe` sidecar。Pnpm 子进程通过 `process.execPath` 执行调用方提供的 JavaScript 入口。当调用方暴露 `.cmd` shim 时,构建器会通过 `PNPM_HOME` 解析已安装的 `pnpm.mjs` 或 `pnpm.cjs`;如果不存在 JavaScript 入口,构建会失败,而不会启动 shim 或启用命令 shell。 + +必需 GitHub 矩阵会在 `windows-2025` 上构建 `node24-win-x64`,与现有三个目标并列。公开 GitHub 发布与 GitLab 标签流水线都会发布同一组四个运行时 wheel 加纯 SDK wheel。目标解析、manifest、矩阵、发布内容与文档均不包含 Windows arm64。 + +### Installed-wheel 行为 + +Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配的 SDK 与 `win_amd64` 运行时 wheel,切换到 checkout 外的目录,清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE`,再运行与其他目标相同的 `--scenario all --installed-wheel` 黑盒测试。可信拉取请求还会运行相同的双轮 `sdk-live` 真实提供方场景。Fork 与 Dependabot head 不会获得密钥。 + +成功收到 shutdown 响应后,Python 客户端会关闭 stdin,并在已配置的 shutdown 超时内等待 `dsh` 上下文退出及刷写持久 session 状态,然后才回退到终止进程。Shutdown 失败时仍立即执行有界终止。该区别会保留 Windows 上最后一个已接受轮次;该平台的 `terminate()` 会强制结束进程,而不是发送可捕获信号。 + +极简黑盒测试在 Windows 上使用持久 `pwsh` 与 `str_replace_editor`,并由 `minimal/win-x64/model-visible.json` 固定预期;Linux 与 macOS 保留持久 Bash 和共享的 `minimal/model-visible.json`。高级进程/subagent 快照与重启/持久日志快照继续由所有目标共享。随附的 [`sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)为可运行 Python 教程选择同一组平台 shell。 + +## Existing decisions and supersession + +本决策部分取代[单文件运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的 Windows 非目标声明,并扩展[安装后 Python wheel 黑盒决策](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)中的必需目标集合。上述 Note 继续负责 SEA 打包、两个 Python distribution、来源校验、密钥处理与通用黑盒场景。 + +## Alternatives considered + +**在 dsh profile 运行时之前增加 Windows。** 否决:针对已退役私有直启载体的测试无法证明 Windows 用户实际获得的形态。Windows 仅定义于唯一的 `dsh` 启动架构。 + +**同时发布 Windows arm64。** 否决:已接受的产品范围只有 x64;增加第二种架构需要独立的原生构建器、wheel 标签、ConPTY 与 ripgrep 载荷校验、installed-wheel 矩阵 lane 及发布产物。 + +**为 Windows 提供较小的冒烟测试套件。** 否决:一个平台 wheel 不能借用其他可执行文件的协议、持久化、worker、MCP、插件、原生工具或真实提供方证据。只有持久 shell surface 使用平台专属预期,其余快照继续共享。 + +**只通过 PowerShell workflow 步骤运行 Windows 命令。** 否决:这会在可复用构建主体中复制 Linux/macOS 的安装与黑盒测试序列。Git Bash 提供通用 workflow 语法;只有虚拟环境可执行程序选择与产品载荷名称因平台而异。 + +## Consequences + +Python 安装现在会选择无需 Node 的 Windows x64 运行时,并与 Linux、macOS 使用同一套显式 home 与 profile 自定义模型。每个拉取请求都要承担第四个可执行文件、运行时 wheel 与完整 keyless 黑盒测试;可信 head 还要承担真实提供方任务。候选发行版验证会保留五个而不是四个 wheel。Windows arm64 用户会收到明确的不支持平台错误,直到另一项原生产品决策提供并证明该载体。 diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml index b454ac5682..91997d47b8 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-11-python-publication-workflow.md -2026-08-11-python-publication-workflow.md: db346dfb96d1657e732c72a3f7a3ca74f92a947a -2026-08-11-python-publication-workflow.zh.md: 17b9b14dd16d85301796a38bb64c464c94a8ab9a +2026-08-11-python-publication-workflow.md: 282bd453013da9b745c601f7b1f4be2cbd133629 +2026-08-11-python-publication-workflow.zh.md: 279dc4b5798d5ceb5968f92c58a7f57c4f2c5cdd diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md index db346dfb96..282bd45301 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md @@ -6,15 +6,15 @@ English | [中文](2026-08-11-python-publication-workflow.zh.md) ## Problem -The Python SDK comprises one platform-independent client wheel and three native runtime wheels that must carry one version and become installable as a set. Public PyPI uploads expose package metadata and files immediately, cannot replace an uploaded filename, and create a temporarily unusable SDK if its exact runtime dependency has not arrived. The private repository needs to exercise the complete native build and validation sequence without publishing any artifact externally. +The Python SDK comprises one platform-independent client wheel and four native runtime wheels that must carry one version and become installable as a set. Public PyPI uploads expose package metadata and files immediately, cannot replace an uploaded filename, and create a temporarily unusable SDK if its exact runtime dependency has not arrived. The private repository needs to exercise the complete native build and validation sequence without publishing any artifact externally. ## Decision -The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all three platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the four resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry run cannot enter either publication job. +The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all four platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the five resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry run cannot enter either publication job. A run with `publish=true` must use the `python-v` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token. -Publication consumes the aggregate artifact produced and checked in the same workflow run. Each publication job verifies the retained `SHA256SUMS` before selecting its upload set. A runtime job uploads all three platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files. +Publication consumes the aggregate artifact produced and checked in the same workflow run. Each publication job verifies the retained `SHA256SUMS` before selecting its upload set. A runtime job uploads all four platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files. Both publication actions disable public attestations. The action still uses Trusted Publishing for authentication, while omitting provenance that would disclose the private publisher repository instead of the public source mirror. diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md index 17b9b14dd1..279dc4b579 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -Python SDK 由一个平台无关的客户端 wheel 包和三个原生运行时 wheel 包组成,它们必须使用同一版本,并作为一组可安装。public PyPI 上传会立即公开包元数据和文件,无法替换已上传的同名文件;如果精确版本的运行时依赖尚未到达,还会产生暂时不可用的 SDK。私有仓库需要在不向外发布任何产物的情况下,执行完整的原生构建与验证流程。 +Python SDK 由一个平台无关的客户端 wheel 包和四个原生运行时 wheel 包组成,它们必须使用同一版本,并作为一组可安装。public PyPI 上传会立即公开包元数据和文件,无法替换已上传的同名文件;如果精确版本的运行时依赖尚未到达,还会产生暂时不可用的 SDK。私有仓库需要在不向外发布任何产物的情况下,执行完整的原生构建与验证流程。 ## 决策 -GitHub 的 `Release (Python)` 工作流为设置 `publish=false` 的手动运行提供无凭据验证。该运行会为全部三个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得四份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,dry-run 运行无法进入任何发布作业。 +GitHub 的 `Release (Python)` 工作流为设置 `publish=false` 的手动运行提供无凭据验证。该运行会为全部四个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得五份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,dry-run 运行无法进入任何发布作业。 设置 `publish=true` 时,运行必须在私有自动化仓库使用 `python-v` 标签,将该仓库的 `github.repository` 与其仓库级 `PYPI_PUBLISHER_REPOSITORY` 变量匹配,找到 `PUBLIC_PYPI_RELEASE_ENABLED=true`,并分别获得 GitHub `pypi-runtime` 和 `pypi` 环境对运行时与 SDK 发布的批准。只读公开镜像提供包元数据 URL,但不运行发布 Actions。只有两个发布作业获得 `id-token: write`;PyPI Trusted Publishing 会把私有仓库身份换成短期项目凭据,因此仓库不保存 PyPI token。 -发布过程使用同一次工作流运行中生成并检查过的汇总产物。每个发布作业都会在选择上传文件前验证保留的 `SHA256SUMS`。一个运行时作业先上传全部三个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。 +发布过程使用同一次工作流运行中生成并检查过的汇总产物。每个发布作业都会在选择上传文件前验证保留的 `SHA256SUMS`。一个运行时作业先上传全部四个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。 两个发布 action 都会禁用公开 attestation。action 仍使用 Trusted Publishing 进行身份认证,同时不上传会披露私有发布仓库而非公开源码镜像的 provenance。 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml index 635b5df7af..03c1a43a28 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-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/testing/2026-08-23-installed-python-wheel-black-box-ci.md -2026-08-23-installed-python-wheel-black-box-ci.md: a2fd4134d7bff0e74aa2d1afc3590e9cdd90809e -2026-08-23-installed-python-wheel-black-box-ci.zh.md: 203440ed0f6bbe84257474dd69400a64bc480cd3 +2026-08-23-installed-python-wheel-black-box-ci.md: 0ac3bc63ef391536a761ad6db9d0854a3beebe01 +2026-08-23-installed-python-wheel-black-box-ci.zh.md: 365da458d3eb33dbc82dcdafaebea593cc4fe971 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md index a2fd4134d7..0ac3bc63ef 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md @@ -24,13 +24,13 @@ Linux additionally retains its manylinux 2.28 clean-install smoke and GLIBC chec ### Real DeepSeek API -Trusted pull requests run a second installed-wheel check on every native target with `DEEPSEEK_API_KEY_EXTERNAL`, mapped only into a preflight and the live test step. The preflight fails when the secret is empty, so the provider suite cannot self-skip to green. The test starts the public SDK against `https://api.deepseek.com`, asks the model to write an exact sentinel file through Bash, asks a second turn in the same session to read it, and verifies the external bytes, final responses, completed turn reasons, model-requested tool calls, and the existence and Zstandard framing of its session log. Decoded record content and completed-turn durability are deterministic keyless obligations owned by the restart snapshot rather than inferred from compressed live-provider bytes. +Trusted pull requests run a second installed-wheel check on every native target with `DEEPSEEK_API_KEY_EXTERNAL`, mapped only into a preflight and the live test step. The preflight fails when the secret is empty, so the provider suite cannot self-skip to green. The test starts the public SDK against `https://api.deepseek.com`, asks the model to write an exact sentinel file through the platform shell, asks a second turn in the same session to read it, and verifies the external line content, final responses, completed turn reasons, model-requested tool calls, and the existence and Zstandard framing of its session log. Decoded record content and completed-turn durability are deterministic keyless obligations owned by the restart snapshot rather than inferred from compressed live-provider bytes. Fork and Dependabot pull requests never receive the repository secret. Their native jobs run the complete keyless path and skip both secret-bearing steps; `pull_request_target` is forbidden because it would execute untrusted code with the key. ### Required targets -The pull-request `python-runtime` job calls the reusable builder for Linux x64, Linux arm64, and macOS arm64. Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. Windows has no runtime wheel in the platform manifest and is not claimed by this decision. +The pull-request `python-runtime` job calls the reusable builder for Linux x64, Linux arm64, macOS arm64, and Windows x64. Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. The [Windows x64 runtime decision](../architecture/2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and its PowerShell-specific minimal snapshot. ## Existing decisions and supersession @@ -38,7 +38,7 @@ This decision supersedes the single-target topology in the archived [required Py ## Alternatives considered -**Keep Linux x64 as the only required carrier.** Rejected because native addons, executable construction, wheel tags, and helper files differ across the three published targets. Release-time discovery is too late for an artifact that every Python SDK installation selects by platform. +**Keep Linux x64 as the only required carrier.** Rejected because native addons, executable construction, wheel tags, and helper files differ across the four published targets. Release-time discovery is too late for an artifact that every Python SDK installation selects by platform. **Run full behavior before wheel construction and keep two small installed smokes.** Rejected because that proves the executable against source imports, then proves too little through the distribution users install. The clean installed environment is the stronger common location for the same scenarios. @@ -48,4 +48,4 @@ This decision supersedes the single-target topology in the archived [required Py ## Consequences -Every pull request pays for three native executable and wheel builds plus deterministic installed-artifact scenarios. Trusted same-repository pull requests also pay for one two-turn DeepSeek task per target. In exchange, the required result describes the files Python users install, proves every published carrier before merge, and cannot pass by importing the checkout or silently skipping the real provider. +Every pull request pays for four native executable and wheel builds plus deterministic installed-artifact scenarios. Trusted same-repository pull requests also pay for one two-turn DeepSeek task per target. In exchange, the required result describes the files Python users install, proves every published carrier before merge, and cannot pass by importing the checkout or silently skipping the real provider. diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md index 203440ed0f..365da458d3 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md @@ -24,13 +24,13 @@ Linux 另外保留 manylinux 2.28 干净安装冒烟测试与 GLIBC 检查。mac ### 真实 DeepSeek API -可信拉取请求会在每个原生目标上运行第二项安装后 wheel 检查,并且只在预检与 live 测试步骤中把 `DEEPSEEK_API_KEY_EXTERNAL` 映射进去。密钥为空时预检失败,因此提供方测试不能通过自行 skip 产生假绿。该测试通过公开 SDK 访问 `https://api.deepseek.com`,要求模型通过 Bash 写入内容精确的 sentinel 文件,再在同一 session 的第二个轮次中读取它,并校验外部文件字节、最终响应、已完成的轮次结束原因、模型请求的工具调用,以及 session 日志存在且采用 Zstandard framing。解码后的记录内容与已完成轮次的持久性是由 restart 快照负责的确定性 keyless 要求,不从压缩后的 live 提供方字节推断。 +可信拉取请求会在每个原生目标上运行第二项安装后 wheel 检查,并且只在预检与 live 测试步骤中把 `DEEPSEEK_API_KEY_EXTERNAL` 映射进去。密钥为空时预检失败,因此提供方测试不能通过自行 skip 产生假绿。该测试通过公开 SDK 访问 `https://api.deepseek.com`,要求模型通过当前平台 shell 写入内容精确的 sentinel 文件,再在同一 session 的第二个轮次中读取它,并校验外部文件行内容、最终响应、已完成的轮次结束原因、模型请求的工具调用,以及 session 日志存在且采用 Zstandard framing。解码后的记录内容与已完成轮次的持久性是由 restart 快照负责的确定性 keyless 要求,不从压缩后的 live 提供方字节推断。 Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 job 运行完整 keyless 路径并跳过两个带密钥的步骤;禁止使用 `pull_request_target`,因为它会让不可信代码带着密钥执行。 ### 必需目标 -拉取请求的 `python-runtime` job 会针对 Linux x64、Linux arm64 与 macOS arm64 调用可复用构建器。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。Windows 不在运行时平台 manifest 中,本决策不声称支持它。 +拉取请求的 `python-runtime` job 会针对 Linux x64、Linux arm64、macOS arm64 与 Windows x64 调用可复用构建器。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。[Windows x64 运行时决策](../architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及其 PowerShell 专属极简快照。 ## Existing decisions and supersession @@ -38,7 +38,7 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ## Alternatives considered -**只保留 Linux x64 必需载体。** 否决:三个已发布目标的原生 addon、可执行文件构建、wheel 包标签与 helper 文件不同。等到发布时才发现问题,对每个 Python SDK 安装都会按平台选择的产物而言太晚。 +**只保留 Linux x64 必需载体。** 否决:四个已发布目标的原生 addon、可执行文件构建、wheel 包标签与 helper 文件不同。等到发布时才发现问题,对每个 Python SDK 安装都会按平台选择的产物而言太晚。 **在 wheel 构建前运行完整行为,并保留两个很小的安装后冒烟测试。** 否决:这只能证明可执行文件配合源码 import 工作,再通过 distribution 证明很少的行为。干净安装环境是在同一批场景中验证用户实际安装内容的更强位置。 @@ -48,4 +48,4 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ## Consequences -每个拉取请求都会承担三个原生可执行文件及 wheel 包构建,并运行确定性的安装后产物场景。可信的同仓库拉取请求还会在每个目标上承担一次双轮 DeepSeek 任务。相应地,必需结果描述 Python 用户实际安装的文件,在合并前证明每个已发布载体,并且不能通过导入 checkout 或静默跳过真实提供方而通过。 +每个拉取请求都会承担四个原生可执行文件及 wheel 包构建,并运行确定性的安装后产物场景。可信的同仓库拉取请求还会在每个目标上承担一次双轮 DeepSeek 任务。相应地,必需结果描述 Python 用户实际安装的文件,在合并前证明每个已发布载体,并且不能通过导入 checkout 或静默跳过真实提供方而通过。 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index a372e966ac..d9f2580549 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 61094a277d2b91063a0d368ec30f444eeb132128 -development.zh.md: a73de6e091050cebb0b26037a7cca3adc814d961 +development.md: f0d448cf4c4ce21895b3f8b0cf43db7ab052caea +development.zh.md: 74e2a6a83ca5ff5ac5b820ed6fdc8d72c0a48798 diff --git a/python/development.md b/python/development.md index 61094a277d..f0d448cf4c 100644 --- a/python/development.md +++ b/python/development.md @@ -13,7 +13,7 @@ pnpm install pnpm exec tsx scripts/build-exe-for-python-sdk.ts ``` -Use `--skip-build` when the required `lib/` artifacts already exist, or `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64` to select platforms. Products land in `dist-exe/` and the script syncs the selected carriers into `python/sdk-runtime/`. macOS builds also sync the matching spawn helper required by `node-pty`. +Use `--skip-build` when the required `lib/` artifacts already exist, or `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64` to select platforms. Build each target on its native architecture. Products land in `dist-exe/` and the script syncs the selected carriers into `python/sdk-runtime/`. Windows emits `.exe` and `-rg.exe`; macOS also syncs the matching spawn helper required by `node-pty`. ## Validate the SDK @@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` -Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the shipped `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job. `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. +Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the Linux/macOS `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages; `minimal/win-x64/model-visible.json` pins its PowerShell counterpart. A plugin that contributes an unintended system section or user message therefore fails the job, and every message the profile emits is compared. `advanced/` pins one complex process's SDK result and parent/child session logs across every target. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs across every target. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. Trusted pull requests also run `--scenario sdk-live --installed-wheel` on every native target. That scenario performs two tool-using turns against `https://api.deepseek.com`, verifies the created file externally, and fails when the repository secret is absent instead of self-skipping. Fork and Dependabot pull requests run the complete keyless installed-wheel path but receive no key. @@ -79,11 +79,11 @@ pip install \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` -The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS 14 or newer on arm64. A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. +The runtime distribution is wheel-only. The release pipeline publishes four platform wheels with the pure SDK wheel: Linux x64, Linux arm64, macOS 14 or newer on arm64, and Windows x64 (`win_amd64`). A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. ## Validate a release candidate -Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all four wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry run cannot enter either publication job. +Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all five wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry run cannot enter either publication job. Public publication runs from the private automation repository; package metadata points to the separate read-only public source mirror, which does not run release Actions. The private repository defines the repository variable `PYPI_PUBLISHER_REPOSITORY` as its own `owner/name` and keeps `PUBLIC_PYPI_RELEASE_ENABLED=false` except during an intentional release. diff --git a/python/development.zh.md b/python/development.zh.md index a73de6e091..74e2a6a83c 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -13,7 +13,7 @@ pnpm install pnpm exec tsx scripts/build-exe-for-python-sdk.ts ``` -所需 `lib/` 产物已存在时使用 `--skip-build`;如需选择平台,请使用 `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64`。产物写入 `dist-exe/`,脚本会将所选载体同步到 `python/sdk-runtime/`。macOS 构建还会同步 `node-pty` 所需的配套 spawn 辅助程序。 +所需 `lib/` 产物已存在时使用 `--skip-build`;如需选择平台,请使用 `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64`。每个目标都应在其原生架构上构建。产物写入 `dist-exe/`,脚本会将所选载体同步到 `python/sdk-runtime/`。Windows 会生成 `.exe` 与 `-rg.exe`;macOS 构建还会同步 `node-pty` 所需的配套 spawn 辅助程序。 ## 验证 SDK @@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` -其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定随附 `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 +其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定 Linux/macOS `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息;`minimal/win-x64/model-visible.json` 固定对应的 PowerShell 版本。因此,插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败,且该 profile 发出的每条消息都会参与比对。`advanced/` 跨所有目标固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并跨所有目标固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 可信拉取请求还会在每个原生目标上运行 `--scenario sdk-live --installed-wheel`。该场景面向 `https://api.deepseek.com` 执行两个使用工具的轮次,从外部验证已创建文件,并在仓库密钥缺失时失败而不是自行 skip。Fork 与 Dependabot 拉取请求会运行完整的 keyless 安装后 wheel 路径,但不会获得密钥。 @@ -79,11 +79,11 @@ pip install \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` -运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS 14 或更高版本的 arm64。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 +运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布四个平台 wheel 包:Linux x64、Linux arm64、macOS 14 或更高版本的 arm64,以及 Windows x64(`win_amd64`)。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 ## 验证候选发行版 -手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部四个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。该运行没有注册表凭据,dry-run 运行无法进入任何发布作业。 +手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部五个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。该运行没有注册表凭据,dry-run 运行无法进入任何发布作业。 公开发布从私有自动化仓库运行;包元数据指向独立的只读公开源码镜像,该镜像不运行发布 Actions。私有仓库把仓库变量 `PYPI_PUBLISHER_REPOSITORY` 定义为自身的 `owner/name`,并且只在有意发布期间把 `PUBLIC_PYPI_RELEASE_ENABLED` 从 `false` 改为 `true`。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 51965002ce..044ba3a728 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 1552f2a120938ecab6d05dd244a745bec65bb00a -README.zh.md: 9524617ee1a950080476a91db5ec6e14727518ce +README.md: 28695259928a7edc6e6cf67e737f1012729df5a4 +README.zh.md: f23b253cfe47d9f1ae24568b51d9db810c7a4a9f diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 1552f2a120..2869525992 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Platform runtime wheel for the DeepSeek Harness Python SDK. It packages the norm The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Python module. `dsh` forwards its arguments to the bundled executable and requires a non-empty `DSH_HOME`; it never falls back to `~/.dsh`. -Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory. Linux and macOS wheels include a target-native `-rg` sidecar; macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, and macOS arm64. The wheel tag and payload must match exactly. +Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published. Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. @@ -25,7 +25,7 @@ Unsupported platforms and missing executables or sidecars raise `FileNotFoundErr ## Packaged profile resolution -`dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. +`dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries and Windows ConPTY addons are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. External profile management uses `dsh plugin --profile ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 9524617ee1..f23b253cfe 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ DeepSeek Harness Python SDK 的平台运行时 wheel。它把普通 `dsh` CLI Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模块。`dsh` 将参数转发给内置可执行程序,并要求非空 `DSH_HOME`;它不会回退到 `~/.dsh`。 -生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序;macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64 与 macOS arm64。Wheel tag 必须与载荷严格匹配。 +生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64 与 Windows x64。Wheel tag 必须与载荷严格匹配;不发布 Windows arm64 wheel。 仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 与 sdist 均不包含它。 @@ -25,7 +25,7 @@ Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模 ## 打包后的 profile 解析 -`dsh` 在显式 home 下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理镜像显式运行时 exports、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与原生 addon 一同打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 +`dsh` 在显式 home 下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理镜像显式运行时 exports、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与 Windows ConPTY addon 会同其他原生 addon 一起打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 外部 profile 管理使用 `dsh plugin --profile ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。 From 8101a0d097049639944fb39662f07fbd1c5a4794 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:18:59 +0800 Subject: [PATCH 081/138] fix(python): make Windows release paths native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the GitHub Windows runtime leg under the runner’s native PowerShell instead of inheriting the POSIX Bash body. POSIX and Windows now own explicit output resolution, virtual-environment setup, environment scrubbing, and keyless/live black-box commands, while portable build commands continue to use each runner’s default shell. Put the pinned uv installation on the GitLab Windows job PATH before either the smoke or release builder invokes it. Reject a runtime executable whose basename does not match the selected platform manifest, and reject Intel macOS at platform selection instead of reporting a misleading missing artifact. Add a complete PowerShell path to the published Python tutorial and record the three-phase shutdown-time bound in the Windows runtime decision. Workflow, Python, and bilingual documentation tests pin the resulting behavior. --- ...3-python-sdk-windows-x64-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-windows-x64-runtime.md | 4 +- ...08-23-python-sdk-windows-x64-runtime.zh.md | 4 +- .../workflows/build-exe-for-python-sdk.yml | 145 +++++++++++++----- .gitlab-ci.yml | 1 + docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 43 ++++++ docs/user/guide/python-sdk.zh.md | 43 ++++++ .../src/deepseek_harness_runtime/__init__.py | 7 +- python/sdk/tests/test_release_version.py | 13 ++ python/sdk/tests/test_runtime_resolution.py | 8 + scripts/build-python-release.py | 4 + scripts/ci-workflow.spec.ts | 57 ++++--- 13 files changed, 270 insertions(+), 67 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml index 732ea6c39f..0da9832af7 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.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-23-python-sdk-windows-x64-runtime.md -2026-08-23-python-sdk-windows-x64-runtime.md: 57c3ac66517d62528464521ba37e9c644899d1ca -2026-08-23-python-sdk-windows-x64-runtime.zh.md: e50efac91bed33f6559386ebdbb3deaa52c9d3ca +2026-08-23-python-sdk-windows-x64-runtime.md: b4ba54d9bd8e7a2eaa9277116a7868a1942d0531 +2026-08-23-python-sdk-windows-x64-runtime.zh.md: 6f05817b647a152cec626f09866f415d164064ec diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md index 57c3ac6651..b4ba54d9bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md @@ -26,7 +26,7 @@ The required GitHub matrix builds `node24-win-x64` on `windows-2025` beside the The Windows lane creates a clean Windows virtual environment, installs the exact SDK and `win_amd64` runtime wheels, changes to a directory outside the checkout, unsets `PYTHONPATH` and `DSH_RUNTIME_MODE`, and runs the same `--scenario all --installed-wheel` blackbox as every other target. Trusted pull requests also run the same two-turn `sdk-live` provider scenario. Fork and Dependabot heads receive no key. -After a successful shutdown response, the Python client closes stdin and waits within the configured shutdown timeout for the `dsh` context to exit and flush durable session state before terminating it. A failed shutdown retains immediate bounded termination. This distinction preserves the final accepted turn on Windows, where `terminate()` force-kills the process rather than delivering a catchable signal. +After a successful shutdown response, the Python client closes stdin and waits within the configured shutdown timeout for the `dsh` context to exit and flush durable session state before terminating it. A failed shutdown retains immediate bounded termination. `shutdown_timeout_seconds` bounds each of the shutdown request, EOF grace, and termination-confirmation phases, so a pathological close can approach three times that value before the final kill. This distinction preserves the final accepted turn on Windows, where `terminate()` force-kills the process rather than delivering a catchable signal. The minimal blackbox uses persistent `pwsh` plus `str_replace_editor` on Windows and owns `minimal/win-x64/model-visible.json`; Linux and macOS retain persistent Bash and the shared `minimal/model-visible.json`. The advanced process/subagent snapshot and restart/durable-log snapshot remain shared across all targets. The shipped [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) selects the same platform shell pair for the runnable Python tutorial. @@ -42,7 +42,7 @@ This decision partially supersedes the Windows non-goal in the [single-file runt **Give Windows a smaller smoke suite.** Rejected because a platform wheel cannot borrow protocol, persistence, worker, MCP, plugin, native-tool, or real-provider evidence from another executable. Platform-specific expected output is limited to the persistent shell surface; the remaining snapshots stay shared. -**Run Windows commands through PowerShell workflow steps only.** Rejected for the reusable build body because it would duplicate the Linux/macOS installation and blackbox sequence. Git Bash supplies the common workflow grammar; only virtual-environment executable selection and the product payload names differ. +**Run the Windows leg through Git Bash.** Rejected because the repository requires native `pwsh` on Windows runners and MSYS path conversion would not prove native command behavior. Portable one-line steps use each runner's default shell; path, virtual-environment, and blackbox steps have explicit POSIX and PowerShell forms. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md index e50efac91b..6f05817b64 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md @@ -26,7 +26,7 @@ Python 进程仍按 [Python profile 运行时决策](2026-08-23-python-sdk-dsh-p Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配的 SDK 与 `win_amd64` 运行时 wheel,切换到 checkout 外的目录,清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE`,再运行与其他目标相同的 `--scenario all --installed-wheel` 黑盒测试。可信拉取请求还会运行相同的双轮 `sdk-live` 真实提供方场景。Fork 与 Dependabot head 不会获得密钥。 -成功收到 shutdown 响应后,Python 客户端会关闭 stdin,并在已配置的 shutdown 超时内等待 `dsh` 上下文退出及刷写持久 session 状态,然后才回退到终止进程。Shutdown 失败时仍立即执行有界终止。该区别会保留 Windows 上最后一个已接受轮次;该平台的 `terminate()` 会强制结束进程,而不是发送可捕获信号。 +成功收到 shutdown 响应后,Python 客户端会关闭 stdin,并在已配置的 shutdown 超时内等待 `dsh` 上下文退出及刷写持久 session 状态,然后才回退到终止进程。Shutdown 失败时仍立即执行有界终止。`shutdown_timeout_seconds` 会分别限制 shutdown 请求、EOF 宽限与终止确认阶段,因此异常关闭在最终 kill 前可能接近该值的三倍。该区别会保留 Windows 上最后一个已接受轮次;该平台的 `terminate()` 会强制结束进程,而不是发送可捕获信号。 极简黑盒测试在 Windows 上使用持久 `pwsh` 与 `str_replace_editor`,并由 `minimal/win-x64/model-visible.json` 固定预期;Linux 与 macOS 保留持久 Bash 和共享的 `minimal/model-visible.json`。高级进程/subagent 快照与重启/持久日志快照继续由所有目标共享。随附的 [`sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)为可运行 Python 教程选择同一组平台 shell。 @@ -42,7 +42,7 @@ Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配 **为 Windows 提供较小的冒烟测试套件。** 否决:一个平台 wheel 不能借用其他可执行文件的协议、持久化、worker、MCP、插件、原生工具或真实提供方证据。只有持久 shell surface 使用平台专属预期,其余快照继续共享。 -**只通过 PowerShell workflow 步骤运行 Windows 命令。** 否决:这会在可复用构建主体中复制 Linux/macOS 的安装与黑盒测试序列。Git Bash 提供通用 workflow 语法;只有虚拟环境可执行程序选择与产品载荷名称因平台而异。 +**通过 Git Bash 运行 Windows lane。** 否决:仓库要求 Windows runner 使用原生 `pwsh`,而 MSYS 路径转换无法证明原生命令行为。可移植的单行步骤使用各 runner 的默认 shell;路径、虚拟环境与黑盒步骤分别提供显式 POSIX 和 PowerShell 形式。 ## Consequences diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 056b7a2a2c..0a3dc200f2 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -156,9 +156,6 @@ jobs: fail-fast: false matrix: include: ${{ fromJSON(needs.plan.outputs.matrix) }} - defaults: - run: - shell: bash steps: - uses: actions/checkout@v6 @@ -241,8 +238,9 @@ jobs: DSH_BUILD_CLIENT_PROFILE: official run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} - - name: Resolve platform outputs - id: runtime + - name: Resolve platform outputs (POSIX) + id: runtime-posix + if: runner.os != 'Windows' env: TARGET: ${{ matrix.target }} VERSION: ${{ needs.plan.outputs.version }} @@ -254,27 +252,36 @@ jobs: linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;; macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl ;; - win-x64) - exe="$exe.exe" - wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-win_amd64.whl - ;; *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; esac - if [ "$RUNNER_OS" = Windows ]; then - [ -f "$exe" ] || { echo "::error::$exe missing"; exit 1; } - else - [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } - fi + [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } echo "platform=$platform" >> "$GITHUB_OUTPUT" echo "exe=$exe" >> "$GITHUB_OUTPUT" echo "wheel=$wheel" >> "$GITHUB_OUTPUT" + - name: Resolve platform outputs (Windows) + id: runtime-windows + if: runner.os == 'Windows' + shell: pwsh + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ needs.plan.outputs.version }} + run: | + if ($env:TARGET -ne 'node24-win-x64') { throw "Unsupported runtime target $env:TARGET" } + $platform = 'win-x64' + $exe = Join-Path $PWD 'dist-exe\deepseek-harness-sdk-runtime-win-x64.exe' + $wheel = "deepseek_harness_runtime_bin-$env:VERSION-py3-none-win_amd64.whl" + if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw "Runtime executable is missing at $exe" } + "platform=$platform" >> $env:GITHUB_OUTPUT + "exe=$exe" >> $env:GITHUB_OUTPUT + "wheel=$wheel" >> $env:GITHUB_OUTPUT + - name: Build release-shaped runtime wheel run: >- python scripts/build-python-release.py --package runtime - --platform "${{ steps.runtime.outputs.platform }}" - --runtime-exe "${{ steps.runtime.outputs.exe }}" + --platform "${{ steps.runtime-posix.outputs.platform || steps.runtime-windows.outputs.platform }}" + --runtime-exe "${{ steps.runtime-posix.outputs.exe || steps.runtime-windows.outputs.exe }}" --output-dir dist-python - uses: actions/download-artifact@v8 @@ -282,40 +289,68 @@ jobs: name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - - name: Install local SDK and runtime wheels into a clean venv - id: smoke-venv + - name: Install local SDK and runtime wheels into a clean venv (POSIX) + id: smoke-venv-posix + if: runner.os != 'Windows' env: - RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} + RUNTIME_WHEEL: ${{ steps.runtime-posix.outputs.wheel }} SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail venv="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-smoke-"))')" python -m venv "$venv" - if [ "$RUNNER_OS" = Windows ]; then - smoke_python="$(cygpath -u "$venv")/Scripts/python.exe" - else - smoke_python="$venv/bin/python" - fi + smoke_python="$venv/bin/python" "$smoke_python" -m pip install \ "dist-python/$SDK_WHEEL" \ "dist-python/$RUNTIME_WHEEL" echo "python=$smoke_python" >> "$GITHUB_OUTPUT" - - name: Run installed-wheel keyless black-box tests + - name: Install local SDK and runtime wheels into a clean venv (Windows) + id: smoke-venv-windows + if: runner.os == 'Windows' + shell: pwsh + env: + RUNTIME_WHEEL: ${{ steps.runtime-windows.outputs.wheel }} + SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl + run: | + $venv = (& python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-smoke-"))').Trim() + python -m venv $venv + $smokePython = Join-Path $venv 'Scripts\python.exe' + & $smokePython -m pip install "dist-python/$env:SDK_WHEEL" "dist-python/$env:RUNTIME_WHEEL" + if ($LASTEXITCODE -ne 0) { throw "Wheel installation failed with exit code $LASTEXITCODE" } + "python=$smokePython" >> $env:GITHUB_OUTPUT + + - name: Run installed-wheel keyless black-box tests (POSIX) + if: runner.os != 'Windows' run: | set -euo pipefail blackbox_root="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-"))')" - if [ "$RUNNER_OS" = Windows ]; then blackbox_root="$(cygpath -u "$blackbox_root")"; fi cd "$blackbox_root" env -u PYTHONPATH -u DSH_RUNTIME_MODE \ - "${{ steps.smoke-venv.outputs.python }}" \ + "${{ steps.smoke-venv-posix.outputs.python }}" \ "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ --scenario all \ --installed-wheel - - name: Preflight installed-wheel real API test + - name: Run installed-wheel keyless black-box tests (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $blackboxRoot = (& python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-"))').Trim() + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + Remove-Item Env:DSH_RUNTIME_MODE -ErrorAction SilentlyContinue + Push-Location $blackboxRoot + try { + & "${{ steps.smoke-venv-windows.outputs.python }}" "$env:GITHUB_WORKSPACE\scripts\smoke-python-runtime.py" --scenario all --installed-wheel + if ($LASTEXITCODE -ne 0) { throw "Installed-wheel black-box failed with exit code $LASTEXITCODE" } + } finally { + Pop-Location + } + + - name: Preflight installed-wheel real API test (POSIX) if: >- inputs.ci + && runner.os != 'Windows' && (github.event_name != 'pull_request' || !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]')) @@ -328,9 +363,25 @@ jobs: exit 1 fi - - name: Run installed-wheel real API black-box test + - name: Preflight installed-wheel real API test (Windows) if: >- inputs.ci + && runner.os == 'Windows' + && (github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]')) + shell: pwsh + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + run: | + if ([string]::IsNullOrWhiteSpace($env:DEEPSEEK_API_KEY)) { + throw 'DEEPSEEK_API_KEY_EXTERNAL is empty; the installed-wheel real API test cannot self-skip.' + } + + - name: Run installed-wheel real API black-box test (POSIX) + if: >- + inputs.ci + && runner.os != 'Windows' && (github.event_name != 'pull_request' || !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]')) @@ -340,19 +391,41 @@ jobs: run: | set -euo pipefail blackbox_root="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-live-"))')" - if [ "$RUNNER_OS" = Windows ]; then blackbox_root="$(cygpath -u "$blackbox_root")"; fi cd "$blackbox_root" env -u PYTHONPATH -u DSH_RUNTIME_MODE \ - "${{ steps.smoke-venv.outputs.python }}" \ + "${{ steps.smoke-venv-posix.outputs.python }}" \ "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ --scenario sdk-live \ --installed-wheel + - name: Run installed-wheel real API black-box test (Windows) + if: >- + inputs.ci + && runner.os == 'Windows' + && (github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]')) + shell: pwsh + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + DEEPSEEK_BASE_URL: https://api.deepseek.com + run: | + $blackboxRoot = (& python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-live-"))').Trim() + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + Remove-Item Env:DSH_RUNTIME_MODE -ErrorAction SilentlyContinue + Push-Location $blackboxRoot + try { + & "${{ steps.smoke-venv-windows.outputs.python }}" "$env:GITHUB_WORKSPACE\scripts\smoke-python-runtime.py" --scenario sdk-live --installed-wheel + if ($LASTEXITCODE -ne 0) { throw "Installed-wheel live API smoke failed with exit code $LASTEXITCODE" } + } finally { + Pop-Location + } + - name: Check Linux GLIBC requirements if: runner.os == 'Linux' run: | set -euo pipefail - readelf --version-info "${{ steps.runtime.outputs.exe }}" | tee glibc-versions.txt + readelf --version-info "${{ steps.runtime-posix.outputs.exe }}" | tee glibc-versions.txt maximum="$(sed -n 's/.*Name: GLIBC_\([0-9.]*\).*/\1/p' glibc-versions.txt | sort -V | tail -1)" [ -n "$maximum" ] || { echo "::error::No GLIBC requirements found"; exit 1; } dpkg --compare-versions "$maximum" le 2.28 || { @@ -363,7 +436,7 @@ jobs: - name: Check macOS deployment target if: runner.os == 'macOS' env: - EXE: ${{ steps.runtime.outputs.exe }} + EXE: ${{ steps.runtime-posix.outputs.exe }} run: >- python3 scripts/check-macos-deployment-target.py "$EXE" "$EXE-spawn-helper" @@ -372,7 +445,7 @@ jobs: if: runner.os == 'Linux' env: RUNNER_ARCH: ${{ runner.arch }} - RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} + RUNTIME_WHEEL: ${{ steps.runtime-posix.outputs.wheel }} SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail @@ -392,7 +465,7 @@ jobs: - uses: actions/upload-artifact@v7 with: - name: ${{ steps.runtime.outputs.wheel }} - path: dist-python/${{ steps.runtime.outputs.wheel }} + name: ${{ steps.runtime-posix.outputs.wheel || steps.runtime-windows.outputs.wheel }} + path: dist-python/${{ steps.runtime-posix.outputs.wheel || steps.runtime-windows.outputs.wheel }} if-no-files-found: error retention-days: 7 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0a663cadf7..008200560c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -112,6 +112,7 @@ runtime-windows-x64: - $env:DSH_WHEEL_VERSION = (& .ci-python\Scripts\python.exe -c 'import runpy; release = runpy.run_path("scripts/build-python-release.py"); print(release["pep440_version"](release["repository_version"]()))') - if ($env:CI_COMMIT_TAG -ne "python-v$env:DSH_VERSION") { throw "Tag $env:CI_COMMIT_TAG does not match package.json version $env:DSH_VERSION" } - .ci-python\Scripts\python.exe -m pip install uv==0.11.23 + - $env:Path = (Join-Path $PWD ".ci-python\Scripts") + [IO.Path]::PathSeparator + $env:Path script: - corepack enable - pnpm install --frozen-lockfile diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 10a8c18c63..cea6e81135 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/python-sdk.md -python-sdk.md: 5fd8b35c08acdd0f0ff457547ca62b31e12994d5 -python-sdk.zh.md: 354d6829dc07056556d19ddfca68a95ad3a5b47f +python-sdk.md: 388b259f0adbba11b7d359fcf861980cf0a3bec7 +python-sdk.zh.md: 2cc23e5cd1d7d7df5ad4b27441c54e6c3239c917 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 5fd8b35c08..388b259f0a 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -14,6 +14,8 @@ This tutorial installs the published Python SDK, runs the shipped standalone min ## Install the SDK +### Linux and macOS + ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness @@ -22,19 +24,40 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` +### Windows PowerShell + +```powershell +git clone https://github.com/deepseek-ai/deepseek-harness.git +Set-Location deepseek-harness +py -3.10 -m venv .venv +.venv\Scripts\Activate.ps1 +python -m pip install deepseek-harness-sdk +``` + The installation includes a matching native runtime wheel and the `dsh` command. Normal SDK execution needs no system Node.js. Repository contributors who build the artifacts should use the [Python contributor workflow](../../../python/development.md). ## Run the checked-in example Export the credential and, when needed, a compatible proxy endpoint: +### Linux and macOS + ```sh export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 ``` +### Windows PowerShell + +```powershell +$env:DEEPSEEK_API_KEY = "sk-your-key-here" +# $env:DEEPSEEK_BASE_URL = "http://127.0.0.1:8000/v1" +``` + Run one task with explicit workspace and home paths: +### Linux and macOS + ```sh python examples/python-sdk-agent/minimal.py \ --workspace /absolute/path/to/disposable-workspace \ @@ -43,6 +66,16 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` +### Windows PowerShell + +```powershell +python examples/python-sdk-agent/minimal.py ` + --workspace C:\work\disposable-workspace ` + --dsh-home C:\work\example-dsh-home ` + --session-id example-001 ` + "Inspect the repository and fix the failing tests." +``` + The script prints the final assistant response. The selected home receives the generated `sdk-minimal` profile, installed plugins, and uncompressed JSONL session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. ## Use the SDK in your program @@ -76,12 +109,22 @@ The SDK starts the bundled `dsh --profile sdk-minimal` process lazily and reuses Use `dsh plugin` for dependencies and bundle layers that should persist in this home: +### Linux and macOS + ```sh export DSH_HOME=/absolute/path/to/example-dsh-home dsh --profile sdk-minimal --dump-default-config >/dev/null dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` +### Windows PowerShell + +```powershell +$env:DSH_HOME = "C:\work\example-dsh-home" +dsh --profile sdk-minimal --dump-default-config | Out-Null +dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle +``` + The first command initializes the shipped standalone profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 354d6829dc..2cc23e5cd1 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -14,6 +14,8 @@ ## 安装 SDK +### Linux 与 macOS + ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness @@ -22,19 +24,40 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` +### Windows PowerShell + +```powershell +git clone https://github.com/deepseek-ai/deepseek-harness.git +Set-Location deepseek-harness +py -3.10 -m venv .venv +.venv\Scripts\Activate.ps1 +python -m pip install deepseek-harness-sdk +``` + 安装内容包含匹配的原生运行时 wheel 与 `dsh` 命令。普通 SDK 运行不需要系统 Node.js。需要构建产物的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.zh.md)。 ## 运行检入示例 导出凭据;使用兼容代理时再设置 endpoint: +### Linux 与 macOS + ```sh export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 ``` +### Windows PowerShell + +```powershell +$env:DEEPSEEK_API_KEY = "sk-your-key-here" +# $env:DEEPSEEK_BASE_URL = "http://127.0.0.1:8000/v1" +``` + 使用显式 workspace 与 home 路径运行一个任务: +### Linux 与 macOS + ```sh python examples/python-sdk-agent/minimal.py \ --workspace /absolute/path/to/disposable-workspace \ @@ -43,6 +66,16 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` +### Windows PowerShell + +```powershell +python examples/python-sdk-agent/minimal.py ` + --workspace C:\work\disposable-workspace ` + --dsh-home C:\work\example-dsh-home ` + --session-id example-001 ` + "Inspect the repository and fix the failing tests." +``` + 脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk-minimal` profile、已安装插件,以及 `sessions/` 下的未压缩 JSONL 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 ## 在程序中使用 SDK @@ -76,12 +109,22 @@ SDK 会延迟启动内置的 `dsh --profile sdk-minimal` 进程,并复用到 需要在该 home 中持久保存依赖与 bundle 层时,使用 `dsh plugin`: +### Linux 与 macOS + ```sh export DSH_HOME=/absolute/path/to/example-dsh-home dsh --profile sdk-minimal --dump-default-config >/dev/null dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` +### Windows PowerShell + +```powershell +$env:DSH_HOME = "C:\work\example-dsh-home" +dsh --profile sdk-minimal --dump-default-config | Out-Null +dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle +``` + 第一个命令初始化随附的独立 profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 4834a029d1..2081aa5070 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -116,7 +116,12 @@ def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]: def _current_platform_tag() -> str: plat = _PLATFORM_TAGS.get(sys.platform) arch = _ARCH_TAGS.get(platform.machine().lower()) - if plat is None or arch is None or (plat == "win" and arch != "x64"): + if ( + plat is None + or arch is None + or (plat == "win" and arch != "x64") + or (plat == "macos" and arch != "arm64") + ): raise FileNotFoundError( "no bundled DeepSeek Harness SDK runtime exists for this platform " f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 829c3b73f8..37d2a01734 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -137,3 +137,16 @@ def test_stage_runtime_copies_platform_payload( assert (destination / "THIRD_PARTY_NOTICES.md").read_bytes() == ( ROOT / "THIRD_PARTY_NOTICES.md" ).read_bytes() + + +def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) -> None: + executable = tmp_path / "renamed.exe" + executable.write_bytes(b"runtime") + + with pytest.raises(ValueError, match="must be named deepseek-harness-sdk-runtime-win-x64.exe"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + "deepseek-harness-sdk-runtime-win-x64.exe", + ) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index beaf5cfd6b..0f06deb28e 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -79,6 +79,14 @@ def test_current_platform_supports_windows_x64_only(monkeypatch: pytest.MonkeyPa runtime._current_platform_tag() +def test_current_platform_rejects_macos_x64(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(runtime.sys, "platform", "darwin") + monkeypatch.setattr(runtime.platform, "machine", lambda: "x86_64") + + with pytest.raises(FileNotFoundError, match="macOS arm64"): + runtime._current_platform_tag() + + def test_runtime_requires_ripgrep_sidecar( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 307fe759dd..085974f921 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -208,6 +208,10 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: + if executable.name != executable_name: + raise ValueError( + f"runtime executable must be named {executable_name}, got {executable.name}" + ) copy_package(ROOT / "python" / "sdk-runtime", destination) stage_license_files(destination, include_notices=True) rewrite_version(destination / "pyproject.toml", version) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index dcaa5e19a2..7f60d75352 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -390,12 +390,19 @@ describe('Python release workflows', () => { const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28') const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS deployment target') const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container') - const cleanVenv = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv') - const installedKeyless = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests') - const realApiPreflight = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test') - const installedRealApi = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test') - if (!isRecord(cleanVenv) || !isRecord(installedKeyless) || !isRecord(realApiPreflight) || !isRecord(installedRealApi)) { - throw new TypeError('Python wheel builder must define installed-wheel keyless and real API steps') + const cleanVenvPosix = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv (POSIX)') + const cleanVenvWindows = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv (Windows)') + const installedKeylessPosix = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests (POSIX)') + const installedKeylessWindows = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests (Windows)') + const realApiPreflightPosix = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test (POSIX)') + const realApiPreflightWindows = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test (Windows)') + const installedRealApiPosix = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test (POSIX)') + const installedRealApiWindows = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test (Windows)') + if (!isRecord(cleanVenvPosix) || !isRecord(cleanVenvWindows) + || !isRecord(installedKeylessPosix) || !isRecord(installedKeylessWindows) + || !isRecord(realApiPreflightPosix) || !isRecord(realApiPreflightWindows) + || !isRecord(installedRealApiPosix) || !isRecord(installedRealApiWindows)) { + throw new TypeError('Python wheel builder must define native POSIX and Windows installed-wheel steps') } expect(call.inputs).toHaveProperty('targets') expect(call.inputs).toMatchObject({ @@ -408,7 +415,7 @@ describe('Python release workflows', () => { expect(workflow.concurrency).toMatchObject({ group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}', }) - expect(build.defaults).toMatchObject({ run: { shell: 'bash' } }) + expect(build.defaults).toBeUndefined() expect(plan.if).toContain('inputs.ci') expect(plan.if).toContain('inputs.release') expect(JSON.stringify(plan.steps)).toContain('pep440_version') @@ -423,6 +430,7 @@ describe('Python release workflows', () => { expect(workflowJson).toContain('/work/dist-python/$RUNTIME_WHEEL') expect(workflowJson).not.toContain('--find-links dist-python') expect(workflowJson).not.toContain('--find-links /work/dist-python') + expect(workflowJson).not.toContain('cygpath') expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') @@ -434,27 +442,29 @@ describe('Python release workflows', () => { expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" }) expect(JSON.stringify(macosCheck)).toContain('scripts/check-macos-deployment-target.py') expect(JSON.stringify(macosCheck)).toContain('$EXE-spawn-helper') - expect(JSON.stringify(installedKeyless)).toContain('--scenario all') - expect(JSON.stringify(installedKeyless)).toContain('--installed-wheel') - expect(JSON.stringify(installedKeyless)).toContain('env -u PYTHONPATH') - expect(JSON.stringify(installedKeyless)).toContain('-u DSH_RUNTIME_MODE') - expect(JSON.stringify(cleanVenv)).toContain('Scripts/python.exe') - expect(realApiPreflight).toMatchObject({ + expect(JSON.stringify(installedKeylessPosix)).toContain('--scenario all') + expect(JSON.stringify(installedKeylessPosix)).toContain('env -u PYTHONPATH') + expect(JSON.stringify(installedKeylessWindows)).toContain('--scenario all --installed-wheel') + expect(installedKeylessWindows).toMatchObject({ if: "runner.os == 'Windows'", shell: 'pwsh' }) + expect(cleanVenvWindows).toMatchObject({ if: "runner.os == 'Windows'", shell: 'pwsh' }) + expect(JSON.stringify(cleanVenvWindows)).toContain('Scripts\\\\python.exe') + expect(realApiPreflightPosix).toMatchObject({ env: { DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}' }, }) - expect(String(realApiPreflight.if)).toContain('inputs.ci') - expect(String(realApiPreflight.if)).toContain('head.repo.fork') - expect(String(realApiPreflight.if)).toContain('dependabot[bot]') - expect(installedRealApi).toMatchObject({ + expect(String(realApiPreflightPosix.if)).toContain('inputs.ci') + expect(String(realApiPreflightPosix.if)).toContain('head.repo.fork') + expect(String(realApiPreflightPosix.if)).toContain('dependabot[bot]') + expect(realApiPreflightWindows).toMatchObject({ shell: 'pwsh' }) + expect(installedRealApiPosix).toMatchObject({ env: { DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}', DEEPSEEK_BASE_URL: 'https://api.deepseek.com', }, }) - expect(installedRealApi.if).toBe(realApiPreflight.if) - expect(JSON.stringify(installedRealApi)).toContain('--scenario sdk-live') - expect(JSON.stringify(installedRealApi)).toContain('--installed-wheel') - expect(JSON.stringify(installedRealApi)).toContain('-u DSH_RUNTIME_MODE') + expect(JSON.stringify(installedRealApiPosix)).toContain('--scenario sdk-live') + expect(JSON.stringify(installedRealApiPosix)).toContain('-u DSH_RUNTIME_MODE') + expect(installedRealApiWindows).toMatchObject({ shell: 'pwsh' }) + expect(JSON.stringify(installedRealApiWindows)).toContain('--scenario sdk-live --installed-wheel') expect(manylinuxSmoke).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED') }) @@ -481,12 +491,15 @@ describe('Python release workflows', () => { const workflow = loadWorkflow('.gitlab-ci.yml') const windows = workflow['runtime-windows-x64'] const publish = workflow['publish-python'] - if (!isRecord(windows) || !Array.isArray(windows.script) || !isRecord(publish) || !Array.isArray(publish.needs)) { + if (!isRecord(windows) || !Array.isArray(windows.before_script) || !Array.isArray(windows.script) + || !isRecord(publish) || !Array.isArray(publish.needs)) { throw new TypeError('GitLab CI must define the Windows runtime and aggregate publication jobs') } expect(windows.tags).toEqual(['windows-x64']) expect(windows.variables).toMatchObject({ PKG_TARGET: 'node24-win-x64', PLATFORM: 'win-x64' }) + expect(JSON.stringify(windows.before_script)).toContain('.ci-python\\\\Scripts') + expect(JSON.stringify(windows.before_script)).toContain('[IO.Path]::PathSeparator') expect(JSON.stringify(windows.script)).toContain('win_amd64.whl') expect(JSON.stringify(windows.script)).toContain('--scenario all --installed-wheel') expect(publish.needs).toContainEqual({ job: 'runtime-windows-x64', artifacts: true }) From dff3e18afdafd06cb4b203480b8a68d655dab920 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:07:21 +0800 Subject: [PATCH 082/138] fix(python): budget cold profile initialization The Windows x64 installed-wheel job timed out while waiting for initialize even though the same head passed on rerun. Exact packaged-runtime VM evidence showed a 6.47-second first cold handshake and 2.69-2.94-second warm fresh-home handshakes, leaving too little variance below the public 10-second default.\n\nRaise the independent initialize default to 30 seconds in both Python SDK configuration layers. Ordinary turn and shutdown timeouts remain unchanged, callers retain an explicit override, and tests plus paired documentation pin the public behavior. --- .../2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml | 4 ++-- .../architecture/2026-08-23-python-sdk-windows-x64-runtime.md | 2 ++ .../2026-08-23-python-sdk-windows-x64-runtime.zh.md | 2 ++ python/sdk/README.i18n.yaml | 4 ++-- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- python/sdk/src/deepseek_harness/api.py | 2 +- python/sdk/src/deepseek_harness/client.py | 2 +- python/sdk/tests/test_client.py | 2 ++ 9 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml index 0da9832af7..3db66f3a7c 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.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-23-python-sdk-windows-x64-runtime.md -2026-08-23-python-sdk-windows-x64-runtime.md: b4ba54d9bd8e7a2eaa9277116a7868a1942d0531 -2026-08-23-python-sdk-windows-x64-runtime.zh.md: 6f05817b647a152cec626f09866f415d164064ec +2026-08-23-python-sdk-windows-x64-runtime.md: 59a46d99f9e7ed411aeffbb541bbe3bb0c752078 +2026-08-23-python-sdk-windows-x64-runtime.zh.md: 3ab972aabb8135c8bc6285d129ba7bc9335eb11f diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md index b4ba54d9bd..59a46d99f9 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md @@ -26,6 +26,8 @@ The required GitHub matrix builds `node24-win-x64` on `windows-2025` beside the The Windows lane creates a clean Windows virtual environment, installs the exact SDK and `win_amd64` runtime wheels, changes to a directory outside the checkout, unsets `PYTHONPATH` and `DSH_RUNTIME_MODE`, and runs the same `--scenario all --installed-wheel` blackbox as every other target. Trusted pull requests also run the same two-turn `sdk-live` provider scenario. Fork and Dependabot heads receive no key. +The public Python client gives the initial profile handshake an independent 30-second default through `initialize_timeout_seconds`. The bound accommodates cold Windows x64 executable startup and profile materialization while still failing a stuck runtime; callers may configure it separately from ordinary request timeouts. + After a successful shutdown response, the Python client closes stdin and waits within the configured shutdown timeout for the `dsh` context to exit and flush durable session state before terminating it. A failed shutdown retains immediate bounded termination. `shutdown_timeout_seconds` bounds each of the shutdown request, EOF grace, and termination-confirmation phases, so a pathological close can approach three times that value before the final kill. This distinction preserves the final accepted turn on Windows, where `terminate()` force-kills the process rather than delivering a catchable signal. The minimal blackbox uses persistent `pwsh` plus `str_replace_editor` on Windows and owns `minimal/win-x64/model-visible.json`; Linux and macOS retain persistent Bash and the shared `minimal/model-visible.json`. The advanced process/subagent snapshot and restart/durable-log snapshot remain shared across all targets. The shipped [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) selects the same platform shell pair for the runnable Python tutorial. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md index 6f05817b64..3ab972aabb 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md @@ -26,6 +26,8 @@ Python 进程仍按 [Python profile 运行时决策](2026-08-23-python-sdk-dsh-p Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配的 SDK 与 `win_amd64` 运行时 wheel,切换到 checkout 外的目录,清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE`,再运行与其他目标相同的 `--scenario all --installed-wheel` 黑盒测试。可信拉取请求还会运行相同的双轮 `sdk-live` 真实提供方场景。Fork 与 Dependabot head 不会获得密钥。 +公开 Python 客户端通过 `initialize_timeout_seconds` 为首次 profile 握手提供独立的 30 秒默认上限。该上限可容纳 Windows x64 可执行文件冷启动与 profile 物化,同时仍会使卡死的运行时失败;调用方可将其与普通请求超时分开配置。 + 成功收到 shutdown 响应后,Python 客户端会关闭 stdin,并在已配置的 shutdown 超时内等待 `dsh` 上下文退出及刷写持久 session 状态,然后才回退到终止进程。Shutdown 失败时仍立即执行有界终止。`shutdown_timeout_seconds` 会分别限制 shutdown 请求、EOF 宽限与终止确认阶段,因此异常关闭在最终 kill 前可能接近该值的三倍。该区别会保留 Windows 上最后一个已接受轮次;该平台的 `terminate()` 会强制结束进程,而不是发送可捕获信号。 极简黑盒测试在 Windows 上使用持久 `pwsh` 与 `str_replace_editor`,并由 `minimal/win-x64/model-visible.json` 固定预期;Linux 与 macOS 保留持久 Bash 和共享的 `minimal/model-visible.json`。高级进程/subagent 快照与重启/持久日志快照继续由所有目标共享。随附的 [`sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)为可运行 Python 教程选择同一组平台 shell。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 5295ed3c11..c8ee3ec85f 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: cf9bb3e3ccac4908e9212d8f7247545b5a6b5d8e -README.zh.md: 9acb8f26129144a77a834f94854cdd3f1a200086 +README.md: 1b03fe5553f25da3bc62f8a7eec2a274b0afb66a +README.zh.md: c0bfa8bdd9e2ecbaad0a019a274b94516e219ac6 diff --git a/python/sdk/README.md b/python/sdk/README.md index cf9bb3e3cc..1b03fe5553 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -26,7 +26,7 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 10-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. +`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. ## Customize plugins diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 9acb8f2612..c0bfa8bdd9 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -26,7 +26,7 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 10 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 +`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 ## 自定义插件 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 09286a1ad1..a9a10f993c 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -29,7 +29,7 @@ class DeepSeekHarnessConfig: patches: tuple[str, ...] = () dsh_home: str | None = None env: dict[str, str] = field(default_factory=dict) - initialize_timeout_seconds: float = 10.0 + initialize_timeout_seconds: float = 30.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 base_url: str | None = None diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index f6752a9906..804076636d 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -31,7 +31,7 @@ class HarnessConfig: dsh_home: str | None = None cwd: str | None = None env: dict[str, str] | None = None - initialize_timeout_seconds: float = 10.0 + initialize_timeout_seconds: float = 30.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 _launch_args: tuple[str, ...] | None = None diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index fba315320b..d5ed7dada8 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -873,6 +873,8 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None: ) assert "initialize_timeout_seconds" in DeepSeekHarnessConfig.__dataclass_fields__ assert "initialize_timeout_seconds" in HarnessConfig.__dataclass_fields__ + assert DeepSeekHarnessConfig().initialize_timeout_seconds == 30.0 + assert HarnessConfig().initialize_timeout_seconds == 30.0 for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"): assert removed not in DeepSeekHarnessConfig.__dataclass_fields__ assert removed not in HarnessConfig.__dataclass_fields__ From 15d53e228ad7b854b8b93fc68ad03de24b42451c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 19:10:18 +0800 Subject: [PATCH 083/138] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=BE=9D=E8=B5=96=E5=85=B3=E7=B3=BB=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 9 +++++---- docs/module-graph.zh.md | 9 +++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8ba88343ec..70fff0bf9b 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: aeb35195f3a095b9de694f164dc1111acf5c8197 -module-graph.zh.md: ef3bc3fbce9cad37542eeea6adf936dd70e6581b +module-graph.md: 3df7799a48682d8768661154d8c285ac2ed9af82 +module-graph.zh.md: 7cfe1b2d28fa1757a475790923144ae79ade27af diff --git a/docs/module-graph.md b/docs/module-graph.md index aeb35195f3..3df7799a48 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1444,6 +1444,7 @@ flowchart TD pkg_client_ui_settings_general --> pkg_settings pkg_client_ui_trajectory --> pkg_agent pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_attachment pkg_client_ui_trajectory --> pkg_client_locale pkg_client_ui_trajectory --> pkg_client_ui_conversation pkg_client_ui_trajectory --> pkg_client_ui_renderer @@ -1484,7 +1485,6 @@ flowchart TD pkg_client_ui_chat --> pkg_session_stats pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_crypto pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller @@ -1531,6 +1531,7 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale @@ -1866,15 +1867,15 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`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/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ef3bc3fbce..7cfe1b2d28 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1446,6 +1446,7 @@ flowchart TD pkg_client_ui_settings_general --> pkg_settings pkg_client_ui_trajectory --> pkg_agent pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_attachment pkg_client_ui_trajectory --> pkg_client_locale pkg_client_ui_trajectory --> pkg_client_ui_conversation pkg_client_ui_trajectory --> pkg_client_ui_renderer @@ -1486,7 +1487,6 @@ flowchart TD pkg_client_ui_chat --> pkg_session_stats pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_crypto pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller @@ -1533,6 +1533,7 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale @@ -1868,15 +1869,15 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`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/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | From 5bbaf168d9759f78884a32c361d970544ba037d4 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:25:50 +0800 Subject: [PATCH 084/138] perf(client-modules): batch startup plugin scripts --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 26 +- ...26-07-23-client-plugin-loading-model.zh.md | 26 +- ...ient-shells-and-dynamic-packages.i18n.yaml | 4 +- ...8-15-client-shells-and-dynamic-packages.md | 10 +- ...5-client-shells-and-dynamic-packages.zh.md | 10 +- apps/web/tests/assembled-boot.ts | 89 ++++-- apps/web/tests/smoke-real.e2e.ts | 28 ++ docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 33 +- docs/subsystems/client-modules.zh.md | 33 +- packages/client/hmr/README.i18n.yaml | 4 +- packages/client/hmr/README.md | 4 +- packages/client/hmr/README.zh.md | 4 +- packages/client/hmr/src/client/index.ts | 26 +- packages/client/hmr/src/events.ts | 28 ++ packages/client/hmr/src/index.ts | 51 ++- .../client/hmr/tests/events.client.spec.ts | 23 ++ .../client/hmr/tests/node-half.client.spec.ts | 6 + packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 8 +- packages/client/modules/README.zh.md | 8 +- .../client/modules/src/client/manifest.ts | 87 +++++- packages/client/modules/src/client/system.ts | 37 ++- packages/client/modules/src/index.ts | 294 +++++++++++++++--- .../modules/tests/loader.client.spec.ts | 153 +++++++-- .../modules/tests/node-half.client.spec.ts | 236 ++++++++++++-- packages/client/web/README.i18n.yaml | 4 +- packages/client/web/README.md | 2 +- packages/client/web/README.zh.md | 2 +- packages/client/web/tests/boot.client.spec.ts | 22 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 +- scripts/type-equiv.manifest.json | 10 + 33 files changed, 1047 insertions(+), 243 deletions(-) create mode 100644 packages/client/hmr/tests/events.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index f85c64d77c..99337fc16f 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: 02dadf6e1dc1f2c4fd99907446bc6d07b35ba471 -2026-07-23-client-plugin-loading-model.zh.md: eaf10d32a6b51189867d2a52f76dc190380cbca0 +2026-07-23-client-plugin-loading-model.md: 07c3f2a1f2cb60a6e60e33c81dcf1c9060d7aebb +2026-07-23-client-plugin-loading-model.zh.md: 5d8c553f0d7eda3c05fdaecf1ac2ee6a515b8304 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 02dadf6e1d..07c3f2a1f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -28,7 +28,7 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers The [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) defines the current static and dynamic package sets and the import rules between them. The loading machinery treats every `dsh.client` package as a host-graph row with one ordinary `lib/client.js` factory bundle. Its declaration carries Cordis `inject` edges, synchronous module-table `external` requests, and the optional `immediately` prefetch mark; the composing app owns only the mounted roster. -The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its ordinary factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Runtime arrives through the same pending queue; static React, Cordis, and UI library identities come from the shell seed. +The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row arrives through the application batch; static React, Cordis, and UI library identities come from the shell seed. ### One module system, one plugin governor @@ -38,13 +38,13 @@ The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientM The vendored Loader consumes the module system through its `internal` contract — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`. -### External-script arrival and source maps +### Batched external-script arrival and source maps -Each graph row's `url` goes to a same-origin external classic `') + const applicationAt = html.indexOf( + ``, + ) + const bootstrapAt = html.indexOf(``) const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ') const entryAt = html.indexOf('') - expect(html).not.toContain('') - expect([facadeAt, modulesAt, graphAt, entryAt]).toEqual([...new Set([ - facadeAt, modulesAt, graphAt, entryAt, + expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([ + facadeAt, applicationAt, bootstrapAt, graphAt, entryAt, ])].sort((a, b) => a - b)) target.load({ id: MODULES_ID, factory: () => modulesClient }) - target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) }) - const system = target.create({ boot: graph, staticModules: {} }) + const system = target.create({ + boot: graph, + staticModules: {}, + loadBundle: async (url) => { + expect(url).toBe(APPLICATION_URL) + target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) }) + }, + }) expect(target.mode).toBe('live') expect(target.pendingQueue).toEqual([]) @@ -209,40 +258,161 @@ describe('client bundle activation', () => { expect(String(thrown)).not.toContain('pnpm run build') }) + it('rejects a malformed built source map during composition', () => { + const packageName = '@fixture/malformed-source-map' + const clientPath = writePackage(packageName) + mkdirSync(dirname(clientPath), { recursive: true }) + writeFileSync(clientPath, 'module.exports = {}\n') + writeFileSync(`${clientPath}.map`, '{}\n') + expect(() => construct([packageName])) + .toThrow(`${clientPath}.map is not a regular Source Map v3 object`) + + writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n') + expect(() => construct([packageName])) + .toThrow(`${clientPath}.map is not a regular Source Map v3 object`) + }) + + it('retains one prior immutable batch generation across rebuild recomposition', async () => { + const packageName = '@fixture/batch-rebuild-race' + const clientPath = writePackage(packageName) + mkdirSync(dirname(clientPath), { recursive: true }) + writeFileSync(clientPath, 'module.exports = { generation: 1 }\n') + const { service, route } = constructWithRoute([packageName]) + const first = service.graph().batches[0]!.url + + writeFileSync(clientPath, 'module.exports = { generation: 2 }\n') + service.rebuilt(packageName) + const second = service.graph().batches[0]!.url + expect(second).not.toBe(first) + expect((await routeRequest(route, first)).status).toBe(200) + expect((await routeRequest(route, second)).status).toBe(200) + + writeFileSync(clientPath, 'module.exports = { generation: 3 }\n') + service.rebuilt(packageName) + const third = service.graph().batches[0]!.url + expect((await routeRequest(route, first)).status).toBe(404) + expect((await routeRequest(route, second)).status).toBe(200) + expect((await routeRequest(route, third)).status).toBe(200) + }) + + it('frames bundle and map fields before hashing an immutable revision', () => { + const packageName = '@fixture/framed-artifact-hash' + const clientPath = writePackage(packageName) + mkdirSync(dirname(clientPath), { recursive: true }) + const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["src.ts"]}\n' + writeFileSync(clientPath, 'module.exports = {} ') + writeFileSync(`${clientPath}.map`, map) + const first = construct([packageName]).graph().entries[0]!.rev + + writeFileSync(clientPath, 'module.exports = {}') + writeFileSync(`${clientPath}.map`, ` ${map}`) + const second = construct([packageName]).graph().entries[0]!.rev + expect(second).not.toBe(first) + }) + it('serves the source map beside a registered client bundle', async () => { const packageName = '@fixture/source-map' const clientPath = writePackage(packageName) mkdirSync(dirname(clientPath), { recursive: true }) - writeFileSync(clientPath, 'module.exports = {}\n') - const map = '{"version":3,"sources":["src/client/index.tsx"]}\n' + writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map') + const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["../../../packages/client/demo/src/index.tsx","https://cdn.example.test/library.js"]}\n' writeFileSync(`${clientPath}.map`, map) - const { route } = constructWithRoute([packageName]) - let status = 0 - let headers: Record | undefined - let body = '' - const response = { - writeHead(nextStatus: number, nextHeaders?: Record) { - status = nextStatus - headers = nextHeaders - return response - }, - end(chunk?: Uint8Array) { - body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8') - return response - }, - } as unknown as ServerResponse - - await route.handler({ - method: 'GET', - url: `/plugins/${packageName}/client.js.map`, - } as IncomingMessage, response) - - expect(status).toBe(200) - expect(headers).toEqual({ + const { service, route } = constructWithRoute([packageName]) + const row = service.graph().entries[0]! + const individualScript = await routeRequest(route, row.url) + expect(individualScript.body.toString('utf8')).toContain(`sourceMappingURL=client.js.map?rev=${row.rev}`) + const individual = await routeRequest(route, row.url.replace('/client.js?', '/client.js.map?')) + expect(individual.status).toBe(200) + expect(individual.headers).toEqual({ 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-cache', + 'cache-control': 'public, max-age=31536000, immutable', }) - expect(body).toBe(map) + expect(individual.body.toString('utf8')).toBe(map) + + const batch = service.graph().batches[0]! + expect(batch).toMatchObject({ phase: 'application', entries: [packageName] }) + const batchScript = await routeRequest(route, batch.url) + expect(batchScript.status).toBe(200) + expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable') + expect(batchScript.body.toString('utf8')).toContain('//# sourceMappingURL=client.js.map') + expect(batchScript.body.toString('utf8')).not.toContain('sourceMappingURL=client.js.map?rev=') + expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0) + expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405) + const batchMap = await routeRequest(route, `${batch.url}.map`) + const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown + const parsedIndividualMap = JSON.parse(map) as Record + expect(parsedBatchMap).toMatchObject({ + version: 3, + file: 'client.js', + sections: [{ + offset: { line: 0, column: 0 }, + map: { + ...parsedIndividualMap, + sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'], + }, + }], + }) + expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404) + + writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n') + expect(construct([packageName]).graph().entries[0]?.rev).not.toBe(row.rev) + }) + + it('applies sourceRoot before relocating absolute-looking section sources', async () => { + const packageName = '@fixture/source-root' + const clientPath = writePackage(packageName) + mkdirSync(dirname(clientPath), { recursive: true }) + writeFileSync(clientPath, 'module.exports = {}\n') + writeFileSync(`${clientPath}.map`, JSON.stringify({ + version: 3, + names: [], + mappings: 'AAAA', + sourceRoot: '../root', + sources: ['/absolute.ts'], + })) + const { service, route } = constructWithRoute([packageName]) + const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`) + const map = JSON.parse(response.body.toString('utf8')) as { + sections: { map: { sourceRoot?: string; sources: string[] } }[] + } + expect(map.sections[0]?.map).toMatchObject({ + sources: ['/plugins/@fixture/root/absolute.ts'], + }) + expect(map.sections[0]?.map).not.toHaveProperty('sourceRoot') + }) + + it('maps a non-zero second batch section through a standard source-map consumer', async () => { + const firstName = '@fixture/offset-first' + const secondName = '@fixture/offset-second' + const firstPath = writePackage(firstName) + const secondPath = writePackage(secondName) + for (const [path, source] of [ + [firstPath, '../../../packages/demo/first.ts'], + [secondPath, '../../../packages/demo/second.ts'], + ] as const) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, 'window.first = true\nwindow.second = true\n') + writeFileSync(`${path}.map`, JSON.stringify({ + version: 3, + names: [], + mappings: 'AAAA', + sources: [source], + sourcesContent: ['export {}\n'], + })) + } + const { service, route } = constructWithRoute([firstName, secondName]) + const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`) + const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters[0] + const sections = (payload as unknown as { + sections: { offset: { line: number; column: number } }[] + }).sections + expect(sections.map(section => section.offset)).toEqual([ + { line: 0, column: 0 }, + { line: 3, column: 0 }, + ]) + const consumer = new SourceMap(payload) + expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' }) + expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' }) }) }) diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index e63b485800..855acd9bc7 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/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/web/README.md -README.md: 3208cb202dd9c101f1ab5f3936aac50fae35ab1c -README.zh.md: c6be7daf8a86660627095063590b063b80e14839 +README.md: c95c5601b6e61d434e585bbf1887135fe177efb6 +README.zh.md: 5335760011f2e801503011d49240e08a7638981e diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 3208cb202d..c95c5601b6 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage calls the Host-installed `window.__ModuleLoader__.create()` with `window.__DSH_BOOT__`, the shell's static modules, and any test transport override; the facade returns the constructed module system and parsed manifest after adopting parser-preloaded registrations. This package then prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry uniformly, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The Host owns the graph, parser preloads, and facade; AppWebEntry does not know the bootstrap package id or parse the wire format. +Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage calls the Host-installed `window.__ModuleLoader__.create()` with `window.__DSH_BOOT__`, the shell's static modules, and any test transport override; the facade returns the constructed module system and parsed manifest after adopting the parser-loaded bootstrap batch. This package then prefetches the `immediately` tier, whose shared application-batch URL executes once. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry uniformly, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The Host owns the graph, batch preload, and facade; AppWebEntry does not know the bootstrap package id or parse the wire format. The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading. Fiber updates retain one spinner node and grow its CSS arc as entries first become active; hydration preserves that node and its animation phase until the application commit. React mounting, slot rendering, and application assembly live in [`ui-renderer`](../ui-renderer/README.md); [`ui-layout`](../ui-layout/README.md) owns the assembled browser-title projection. The modules bundle caches its own materialized exports and provides the closed-over system when its ordinary graph entry activates; Cordis service waiting makes graph-row creation order independent from that activation. diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index c6be7daf8a..5335760011 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段调用 Host 安装的 `window.__ModuleLoader__.create()`,传入 `window.__DSH_BOOT__`、外壳静态模块以及可选测试传输覆盖;facade 接纳 parser 预载的 registration 后返回构造好的模块系统与已解析 manifest。本包随后预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,统一创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。Graph、parser preload 与 facade 归 Host 所有;AppWebEntry 不感知 bootstrap package id,也不解析 wire 格式。 +Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段调用 Host 安装的 `window.__ModuleLoader__.create()`,传入 `window.__DSH_BOOT__`、外壳静态模块以及可选测试传输覆盖;facade 接纳 parser 已加载的 bootstrap 批次后返回构造好的模块系统与已解析 manifest。本包随后预取 `immediately` 层级,其共享的 application 批次 URL 只执行一次。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,统一创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。Graph、批次 preload 与 facade 归 Host 所有;AppWebEntry 不感知 bootstrap package id,也不解析 wire 格式。 启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致。fiber 更新会保留同一个 spinner 节点,并在 entry 首次进入 active 时增长其 CSS 圆弧;hydrate 会继续保留该节点及其动画相位,直到应用提交。React 挂载、slot 渲染和应用组装位于 [`ui-renderer`](../ui-renderer/README.zh.md);[`ui-layout`](../ui-layout/README.zh.md) 拥有组装后的浏览器标题投影。Modules bundle 会缓存自身已物化导出,并在其普通图 entry 激活时提供闭包中的系统;Cordis service 等待使图 row 创建顺序不依赖该激活时点。 diff --git a/packages/client/web/tests/boot.client.spec.ts b/packages/client/web/tests/boot.client.spec.ts index def708d2c5..8d75949b78 100644 --- a/packages/client/web/tests/boot.client.spec.ts +++ b/packages/client/web/tests/boot.client.spec.ts @@ -80,7 +80,11 @@ describe('bootstrap failure rendering', () => { await expectBootFailure(() => { installFacade() const duplicate = { id: 'duplicate', url: '/duplicate/client.js', rev: '1' } - win.__DSH_BOOT__ = { rev: 'graph', entries: [duplicate, duplicate] } + win.__DSH_BOOT__ = { + rev: 'graph', + entries: [duplicate, duplicate], + batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['duplicate'] }], + } }, 'duplicate graph entry "duplicate"') }) }) @@ -159,7 +163,16 @@ describe('plugin activation', () => { { id: MODULES_ID, url: '/modules.js', rev: '1' }, { id: 'renderer', url: '/renderer.js', rev: '1' }, ] - win.__DSH_BOOT__ = { rev: 'graph', entries } + win.__DSH_BOOT__ = { + rev: 'graph', + entries, + batches: [{ + phase: 'application', + url: '/application.js', + rev: 'batch', + entries: entries.map(row => row.id), + }], + } const registrations = new Map([ ['/consumer.js', { id: 'consumer', @@ -188,9 +201,8 @@ describe('plugin activation', () => { ]) const entry = new AppWebEntry(container, { loadBundle: async (url) => { - const registration = registrations.get(url) - if (registration === undefined) throw new Error(`missing fixture registration ${url}`) - target.load(registration) + if (url !== '/application.js') throw new Error(`missing fixture batch ${url}`) + for (const registration of registrations.values()) target.load(registration) }, }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 320c44b9aa..65f359f42a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5443,13 +5443,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'VerifiedWebhookDelivery', declaration: 'export interface VerifiedWebhookDelivery {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf;\n readonly receivedAt: number;\n}', }, + { + name: 'WebBootBatch', + declaration: 'export interface WebBootBatch {\n phase: WebBootBatchPhase;\n url: string;\n rev: string;\n entries: string[];\n}', + }, + { + name: 'WebBootBatchPhase', + declaration: 'export type WebBootBatchPhase = \'bootstrap\' | \'application\';', + }, { name: 'WebBootEntry', declaration: 'export interface WebBootEntry {\n id: string;\n url: string;\n rev: string;\n inject?: string[];\n immediately?: boolean;\n external?: string[];\n}', }, { name: 'WebBootGraph', - declaration: 'export interface WebBootGraph {\n rev: string;\n entries: WebBootEntry[];\n}', + declaration: 'export interface WebBootGraph {\n rev: string;\n entries: WebBootEntry[];\n batches: WebBootBatch[];\n}', }, { name: 'WebFetchBody', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3bedaa9804..a2d0152493 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1761,6 +1761,16 @@ "symbol": "WebBootEntry", "source": "packages/client/modules/src/client/manifest.ts" }, + { + "doc": "docs/subsystems/client-modules.md", + "symbol": "WebBootBatchPhase", + "source": "packages/client/modules/src/client/manifest.ts" + }, + { + "doc": "docs/subsystems/client-modules.md", + "symbol": "WebBootBatch", + "source": "packages/client/modules/src/client/manifest.ts" + }, { "doc": "docs/subsystems/client-modules.md", "symbol": "WebBootGraph", From 9ee9a3270c633603c963141e167aaeb21abbdad3 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:18:45 +0800 Subject: [PATCH 085/138] test(web): hold application batch during boot theme check --- apps/web/tests/settings-chrome.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index d1c6ada8ba..562aff5dad 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -192,8 +192,8 @@ describe('web e2e: settings modal and General preferences', () => { .toMatch(/ui-theme:\n\s+preference: dark/) await page.keyboard.press('Escape') - // Hold real plugin bundles so the shell-owned loading page remains observable. - const pluginPattern = /\/plugins\/@deepseek-ai\/dsh-client-ui-theme\/client\.js(?:\?.*)?$/ + // Hold the real application batch so the shell-owned loading page remains observable. + const pluginPattern = /\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/ let releaseBundles = (): void => {} const bundlesReleased = new Promise((resolve) => { releaseBundles = resolve }) await page.route(pluginPattern, async (route) => { From 445de0ab3e5fe1bf5a8695b52e5393ae41f8b631 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:28:59 +0800 Subject: [PATCH 086/138] fix(client-modules): tolerate incomplete source maps --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +-- .../2026-07-23-client-plugin-loading-model.md | 2 +- ...26-07-23-client-plugin-loading-model.zh.md | 2 +- apps/web/tests/smoke-real.e2e.ts | 2 ++ packages/client/modules/README.i18n.yaml | 4 +-- packages/client/modules/README.md | 1 + packages/client/modules/README.zh.md | 1 + packages/client/modules/src/client/system.ts | 7 +++-- packages/client/modules/src/index.ts | 26 ++++++++++--------- .../modules/tests/loader.client.spec.ts | 10 +++++++ .../modules/tests/node-half.client.spec.ts | 14 +++++----- 11 files changed, 47 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 99337fc16f..63d42117b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: 07c3f2a1f2cb60a6e60e33c81dcf1c9060d7aebb -2026-07-23-client-plugin-loading-model.zh.md: 5d8c553f0d7eda3c05fdaecf1ac2ee6a515b8304 +2026-07-23-client-plugin-loading-model.md: a16c022bc2d4f96bd2680a637e02a485ba4f2697 +2026-07-23-client-plugin-loading-model.zh.md: 96c6b85cd6d7bdb0cbfce4479d2cfe5b7e74f2a5 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 07c3f2a1f2..a16c022bc2 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -96,7 +96,7 @@ The current package inventory and build forms live in the [client shell layering One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook. -Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. +Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, revision-stamped individual responses, current batches, and one previous batch generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition. Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 5d8c553f0d..96c6b85cd6 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -96,7 +96,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。 -接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。 +接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、带 revision 的独立响应、当前批次及上一代批次,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。 diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index fcbbd637ac..58facd4b3e 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -265,6 +265,8 @@ describe('dsh web keyless CLI smoke', () => { const page = await newEnglishPage(browser) const pluginScripts: string[] = [] const cacheHeaders = new Map() + // Chromium reports `preload as=script` as Script and reuses that same + // request when the matching script node executes; this count pins both. page.on('request', (request) => { const url = new URL(request.url()) if (request.resourceType() === 'script' && url.pathname.startsWith('/plugins/')) { diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index b5b5df2c80..162e717aee 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/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/modules/README.md -README.md: bd6d53de9d847b02fc7a8b1030efe4f9036e44ab -README.zh.md: d78a2ddc836c1266e92c5ca95b4e54b78bdcbf98 +README.md: 67f6b6216dee90148f78d5b62edbe6009dffb8c9 +README.zh.md: d385b7c3854c9da1c4f22224bc5910ae7cfcdf2f diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index bd6d53de9d..67f6b6216d 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -26,3 +26,4 @@ None; this package neither assembles nor sends a provider request. - **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change. - **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record. +- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, revision-stamped individual response, and generated batch in memory; HMR additionally retains one prior batch generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index d78a2ddc83..d385b7c385 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -26,3 +26,4 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包, - **有意采用扁平模块图**:每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。 - **自身不维护卸载记录**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。 +- **快照式提供会常驻产物字节**:Host 会在内存中保留每个 bundle、可选 sourcemap、带 revision 的独立响应及生成的批次;HMR 还会保留上一代批次。内存会随组合出的客户端产物增长为数份副本,以换取 immutable 响应和一代竞态容忍。 diff --git a/packages/client/modules/src/client/system.ts b/packages/client/modules/src/client/system.ts index 6e17f83494..be2e998a82 100644 --- a/packages/client/modules/src/client/system.ts +++ b/packages/client/modules/src/client/system.ts @@ -26,12 +26,15 @@ const defaultLoadBundle = (url: string): Promise => new Promise((resolve, document.head.append(el) }) -/** Replace the rev query while preserving same-origin relative URLs. */ +/** Replace the rev query while preserving absolute, protocol-relative, or path-relative form. */ function atRevision(url: string, rev: string): string { const absolute = /^[A-Za-z][A-Za-z\d+.-]*:/.test(url) + const protocolRelative = url.startsWith('//') const parsed = new URL(url, 'http://dsh.invalid') parsed.searchParams.set('rev', rev) - return absolute ? parsed.href : `${parsed.pathname}${parsed.search}${parsed.hash}` + if (absolute) return parsed.href + if (protocolRelative) return `//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}` + return `${parsed.pathname}${parsed.search}${parsed.hash}` } /** diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index cdf24ee1f1..27ce53e35a 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -514,14 +514,7 @@ export class ClientModuleRegistry extends Service { const record = this.table.get(id) if (record === undefined) return undefined const bundle = readFileSync(record.meta.clientPath) - let sourceMap: WebPluginRecord['sourceMap'] - try { - sourceMap = sourceMapSnapshot(record.meta.clientPath) - } catch (error) { - // A client rebuild remains reloadable when its development-only map is - // temporarily incomplete; this revision simply exposes no map. - this.ctx.logger.warn(error) - } + const sourceMap = this.readSourceMapSnapshot(record.meta.clientPath) const rev = artifactRevision(bundle, sourceMap) if (rev === record.entry.rev) return rev record.entry = graphRow(id, rev, record.meta) @@ -566,14 +559,13 @@ export class ClientModuleRegistry extends Service { private compose(): WebBootGraph { const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry)) - const records = new Map([...this.table.entries()]) const bootstrap = PARSER_PRELOAD_IDS - .map(id => records.get(id)) + .map(id => this.table.get(id)) .filter((record): record is WebPluginRecord => record !== undefined) const bootstrapIds = new Set(bootstrap.map(record => record.entry.id)) const application = entries .filter(entry => !bootstrapIds.has(entry.id)) - .map(entry => records.get(entry.id)) + .map(entry => this.table.get(entry.id)) .filter((record): record is WebPluginRecord => record !== undefined) const artifacts: BatchArtifact[] = [] if (bootstrap.length > 0) artifacts.push(buildBatch('bootstrap', bootstrap)) @@ -660,7 +652,7 @@ export class ClientModuleRegistry extends Service { } { try { const bundle = readFileSync(clientPath) - const sourceMap = sourceMapSnapshot(clientPath) + const sourceMap = this.readSourceMapSnapshot(clientPath) return { bundle, rev: artifactRevision(bundle, sourceMap), ...(sourceMap === undefined ? {} : { sourceMap }) } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error @@ -668,6 +660,16 @@ export class ClientModuleRegistry extends Service { } } + /** Treat a missing, torn, or malformed development map as an unmapped artifact revision. */ + private readSourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] { + try { + return sourceMapSnapshot(clientPath) + } catch (error) { + this.ctx.logger.warn(error) + return undefined + } + } + /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */ private processOne(entryName: string): boolean { let qualifies = false diff --git a/packages/client/modules/tests/loader.client.spec.ts b/packages/client/modules/tests/loader.client.spec.ts index 4ec0510d59..37813ef6fb 100644 --- a/packages/client/modules/tests/loader.client.spec.ts +++ b/packages/client/modules/tests/loader.client.spec.ts @@ -451,6 +451,16 @@ describe('HMR reset', () => { expect(b.fetched.at(-1)).toBe('https://plugins.example.test/plugins/a/client.js?rev=next') }) + it('preserves a protocol-relative individual endpoint when applying the rebuilt revision', async () => { + const b = bench([ + row('a', { url: '//plugins.example.test/plugins/a/client.js?rev=0' }), + ], { a: () => ({}) }) + await b.loader.import('a', '', {}) + b.loader.invalidate('a', 'next') + await b.loader.prefetch('a') + expect(b.fetched.at(-1)).toBe('//plugins.example.test/plugins/a/client.js?rev=next') + }) + it('uses the current individual revision when a graph-row invalidation omits an override', async () => { const b = bench([row('a')], { a: () => ({}) }) await b.loader.import('a', '', {}) diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 28267e7d81..f22c8e3b18 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -258,18 +258,20 @@ describe('client bundle activation', () => { expect(String(thrown)).not.toContain('pnpm run build') }) - it('rejects a malformed built source map during composition', () => { + it('omits a torn or malformed source map without blocking composition', async () => { const packageName = '@fixture/malformed-source-map' const clientPath = writePackage(packageName) mkdirSync(dirname(clientPath), { recursive: true }) writeFileSync(clientPath, 'module.exports = {}\n') - writeFileSync(`${clientPath}.map`, '{}\n') - expect(() => construct([packageName])) - .toThrow(`${clientPath}.map is not a regular Source Map v3 object`) + writeFileSync(`${clientPath}.map`, '{') + const torn = constructWithRoute([packageName]) + const tornRow = torn.service.graph().entries[0]! + expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8')) + .not.toContain('sourceMappingURL') + expect((await routeRequest(torn.route, `${torn.service.graph().batches[0]!.url}.map`)).status).toBe(404) writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n') - expect(() => construct([packageName])) - .toThrow(`${clientPath}.map is not a regular Source Map v3 object`) + expect(() => construct([packageName])).not.toThrow() }) it('retains one prior immutable batch generation across rebuild recomposition', async () => { From 47bf44a5bb7cc3174556f3c4bbd0d28da96bd061 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:17:44 +0800 Subject: [PATCH 087/138] fix(client-modules,webserver,webworker-runtime): preserve batched boot across transports --- ...-08-19-web-index-injection-table.i18n.yaml | 4 +- .../2026-08-19-web-index-injection-table.md | 6 +-- ...2026-08-19-web-index-injection-table.zh.md | 6 +-- packages/client/modules/src/index.ts | 15 +------ packages/client/web/tests/boot.client.spec.ts | 44 ++++++++++--------- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../src/client/apply-injections.ts | 4 ++ .../tests/client/apply-injections.spec.ts | 21 +++++++++ .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- packages/host/webserver/src/injections.ts | 4 ++ .../host/webserver/tests/webserver.spec.ts | 2 + 16 files changed, 73 insertions(+), 51 deletions(-) create mode 100644 packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml index 90d1f96dff..d3fab99c56 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.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-19-web-index-injection-table.md -2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2 -2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f +2026-08-19-web-index-injection-table.md: 050690946a73ce453946f6d1152c9e86e1ba3aaf +2026-08-19-web-index-injection-table.zh.md: 27d1cbce1dc39ebe22e0b79e52f9ce935279ee69 diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md index 9ed02aa94c..050690946a 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md @@ -10,16 +10,16 @@ The web shell's boot HTML needs three kinds of injection: client-modules' boot p ## Decision -Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`style`/`html`, `head|body` placement). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber. +Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`script-preload`/`style`/`html`, with placement where applicable). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber. -One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). Rows are pure JSON data — that is the both-ends-equivalent discipline. +One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). A `script-preload` row renders a browser preload hint in served HTML and is ignored by the worker interpreter, whose `/plugins` resources exist only behind the tunnel and load on demand. Rows are pure JSON data — that is the both-ends-equivalent discipline. `tapIndex`/`applyIndexTaps` survive as the raw-HTML escape hatch, applied after row rendering; every internal consumer moved to the event. ## Consequences - client-modules and ui-theme no longer regex-edit HTML; the worker's `readBootPayload` service-poking (`clientModules`, `settings`, theme constants through `loader.load`) is deleted; the page-side `installModuleLoaderFacade`, `applyBootTheme`, and `PARSER_PRELOAD_IDS` re-implementations retire. -- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → preloads → global. +- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → application preload → bootstrap script → global. - The served rendering of the manifest global changed from `window.__DSH_BOOT__ =` to `globalThis["__DSH_BOOT__"] =`; no committed snapshot expectation carries that text, so none needed re-recording. - New model-visible or page-visible boot inputs extend the row union; no new tap consumers. diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md index 8ad036766f..27d1cbce1d 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md @@ -10,16 +10,16 @@ Web 壳的启动 HTML 需要三类注入:client-modules 的引导协议(`__M ## Decision -注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection`(`global`/`script`/`script-src`/`style`/`html`,`head|body` 定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。 +注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection`(`global`/`script`/`script-src`/`script-preload`/`style`/`html`,在适用的行上携带定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。 -一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.html(head 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。行是纯 JSON 数据,这是双端等价的纪律。 +一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.html(head 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。`script-preload` 行在 served HTML 中渲染为浏览器预加载提示;worker 解释器忽略它,因为 `/plugins` 资源只存在于 tunnel 后方,并在实际需要时加载。行是纯 JSON 数据,这是双端等价的纪律。 `tapIndex`/`applyIndexTaps` 保留为原始 HTML 变换的逃生口,在行渲染之后执行;内部消费者全部迁走。 ## Consequences - client-modules 与 ui-theme 不再各自正则改 HTML;worker 侧 `readBootPayload` 的 `ctx.get` 手掏(clientModules、settings、theme 常量 loader.load)删除;页面侧 `installModuleLoaderFacade`、`applyBootTheme`、`PARSER_PRELOAD_IDS` 三份重抄退役。 -- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证 队列→preload→全局 三行有序。 +- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证队列→application preload→bootstrap script→全局的顺序。 - `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;已核实无已提交快照期望含此文本,无需重录。 - 新的模型可见/页面可见注入一律走行类型扩展,不再新增 tap 消费者。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 27ce53e35a..f8344207e9 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -351,15 +351,6 @@ const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules' /** Dynamic bundles grouped into the parser bootstrap batch before the Vite shell. */ const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const -/** Escape a graph URL before placing it in a quoted HTML attribute. */ -function escapeHtmlAttribute(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll('<', '<') - .replaceAll('>', '>') -} - /** * The boot protocol as index injection rows. The inline registration queue * precedes the application-batch preload and the blocking bootstrap batch. Its @@ -398,11 +389,7 @@ window.__ModuleLoader__={ const application = graph.batches.find(batch => batch.phase === 'application') const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }] if (application !== undefined) { - rows.push({ - kind: 'html', - placement: 'head', - html: ``, - }) + rows.push({ kind: 'script-preload', src: application.url }) } if (bootstrap !== undefined) { rows.push({ kind: 'script-src', placement: 'head', src: bootstrap.url }) diff --git a/packages/client/web/tests/boot.client.spec.ts b/packages/client/web/tests/boot.client.spec.ts index 8d75949b78..34b13d40ad 100644 --- a/packages/client/web/tests/boot.client.spec.ts +++ b/packages/client/web/tests/boot.client.spec.ts @@ -106,50 +106,54 @@ describe('plugin activation', () => { { id: 'provider', url: '/provider.js', rev: '1' }, { id: 'renderer', url: '/renderer.js', rev: '1' }, ] - win.__DSH_BOOT__ = { rev: 'graph', entries } - target.load({ - id: 'runtime', - factory: require => ({ - apply: () => {}, - marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker, - }), - }) + const applicationUrl = '/application.js' + win.__DSH_BOOT__ = { + rev: 'graph', + entries, + batches: [{ phase: 'application', url: applicationUrl, rev: 'batch', entries: entries.map(row => row.id) }], + } const loaded: string[] = [] - const registrations = new Map([ - ['/consumer.js', { + const registrations: ClientBundleRegistration[] = [ + { id: 'consumer', factory: require => ({ apply: () => { expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider') }, }), - }], - ['/provider.js', { + }, + { id: 'provider', factory: () => ({ apply: () => {}, marker: 'provider' }), - }], - ['/renderer.js', { + }, + { + id: 'runtime', + factory: require => ({ + apply: () => {}, + marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker, + }), + }, + { id: 'renderer', factory: () => ({ apply: (ctx: Context) => { ctx.reflect.provide('uiRenderer', { mount: () => () => {} }) }, }), - }], - ]) + }, + ] transportGlobal.__DSH_TRANSPORT__ = { loadBundle: async (url) => { loaded.push(url) - const registration = registrations.get(url) - if (registration === undefined) throw new Error(`missing fixture registration ${url}`) - target.load(registration) + if (url !== applicationUrl) throw new Error(`missing fixture batch ${url}`) + for (const registration of registrations) target.load(registration) }, } const entry = new AppWebEntry(container) await entry.run() - expect(loaded).toEqual(['/provider.js', '/consumer.js', '/renderer.js']) + expect(loaded).toEqual([applicationUrl]) await entry.dispose() }) diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index d0d0d13a6e..2da19ae9e3 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: 3e9b4fffe0b97a97adf218aa12fd1f4342d3bc6c -README.zh.md: 2552c659d1b735b0cf28b9b0d0808276d31d0a2a +README.md: f8789f8e82005c15cd38cbe77832e486d4a44b9b +README.zh.md: e89ae7fd1e6b5d9ad9aa68edecfd92a81dcaf9b9 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 3e9b4fffe0..f8789f8e82 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -9,7 +9,7 @@ Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` performs the actual fetch and execution on first demand. The tunnel also exposes fetch-shaped transport and the API client. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 2552c659d1..e89ae7fd1e 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -9,7 +9,7 @@ - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时完成实际获取与执行。Tunnel 还暴露 fetch 形传输与 API 客户端。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 diff --git a/packages/experimental/webworker-runtime/src/client/apply-injections.ts b/packages/experimental/webworker-runtime/src/client/apply-injections.ts index 163729a6aa..76e094cd60 100644 --- a/packages/experimental/webworker-runtime/src/client/apply-injections.ts +++ b/packages/experimental/webworker-runtime/src/client/apply-injections.ts @@ -34,6 +34,10 @@ export async function applyIndexInjections( case 'script-src': await loadScript(row.src) break + case 'script-preload': + // The worker tunnel has no browser URL to warm without also executing + // the script; loadScript handles the real request when the row arrives. + break case 'style': { const el = document.createElement('style') el.textContent = row.text diff --git a/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts b/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts new file mode 100644 index 0000000000..60a764e5e0 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { applyIndexInjections } from '../../src/client/apply-injections.ts' + +afterEach(() => { + document.head.innerHTML = '' + document.body.innerHTML = '' +}) + +it('ignores script preload hints and executes script sources through the worker loader', async () => { + const loadScript = vi.fn(async () => {}) + + await applyIndexInjections([ + { kind: 'script-preload', src: '/plugins/preload.js' }, + { kind: 'script-src', placement: 'head', src: '/plugins/execute.js' }, + ], loadScript) + + expect(loadScript).toHaveBeenCalledOnce() + expect(loadScript).toHaveBeenCalledWith('/plugins/execute.js') + expect(document.querySelector('link[rel="preload"]')).toBeNull() +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 65f359f42a..5a190d87d1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3765,7 +3765,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'IndexInjection', - declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};', + declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'script-preload\';\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};', }, { name: 'IndexInjectionPlacement', diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index aae84858b2..8a6b93bb12 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/webserver/README.md -README.md: 0dc8f197f923c2dc4cb2d72ccb5b3a31f5384503 -README.zh.md: d19e4a6be1df0c464d7ac61726e6bfb45a92c8a1 +README.md: c6abc503222fc8bf60d4b6c940eeb1f7910cc9aa +README.zh.md: 430488869c98a86ff669e12acfaee86bae7aa8a3 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 0dc8f197f9..c6abc50322 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); the fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload, rendering with the exported `renderIndexInjections`. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); `script-preload` rows render advisory classic-script preload links. The fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index d19e4a6be1..430488869c 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发,用导出的 `renderIndexInjections` 渲染。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。 +Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.zh.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);`script-preload` 行渲染为 classic script 的提示性预加载链接。fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。 该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认安全姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。 diff --git a/packages/host/webserver/src/injections.ts b/packages/host/webserver/src/injections.ts index 7a61ae0510..5b431918f5 100644 --- a/packages/host/webserver/src/injections.ts +++ b/packages/host/webserver/src/injections.ts @@ -23,6 +23,8 @@ export type IndexInjection = * loader resolves worker-only URLs such as `/plugins/...`). */ | { kind: 'script-src'; placement: IndexInjectionPlacement; src: string } + /** Advisory preload for an external classic script; static workers may ignore it. */ + | { kind: 'script-preload'; src: string } /** A `` } case 'html': diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index e8fa315ecc..198716d778 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -208,6 +208,7 @@ describe('real Loader composition', () => { table.push( { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' }, + { kind: 'script-preload', src: '/plugins/b.js?rev="2"&x=' }, { kind: 'global', name: '__DSH_BOOT__', value: { rev: '' } }, { kind: 'style', text: 'body{margin:0}' }, { kind: 'html', placement: 'head', html: '' }, @@ -222,6 +223,7 @@ describe('real Loader composition', () => { '', '', '', + '', 'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}', '', '', From 9c3a0893f622620bd9016bfabb95ddcfd07cf18b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:15:14 +0800 Subject: [PATCH 088/138] perf(client-modules): defer per-plugin revision hashing Preserve sourcemaps through the production Client build and verify batched loading across Host, HMR, and Web Worker paths. --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 10 +-- ...26-07-23-client-plugin-loading-model.zh.md | 10 +-- apps/web/tests/smoke-real.e2e.ts | 44 +++++++++- docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 34 +++++++- docs/subsystems/client-modules.zh.md | 34 +++++++- packages/client/hmr/README.i18n.yaml | 4 +- packages/client/hmr/README.md | 2 +- packages/client/hmr/README.zh.md | 2 +- packages/client/hmr/src/index.ts | 47 +++++------ .../client/hmr/tests/node-half.client.spec.ts | 50 ++++++++--- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- .../client/modules/src/client/manifest.ts | 4 +- packages/client/modules/src/index.ts | 84 ++++++++++++++++--- .../modules/tests/node-half.client.spec.ts | 68 +++++++++++---- packages/client/tsdown.client.ts | 52 ++++++++---- .../extensions/tool-cordis/src/api-catalog.ts | 10 +++ scripts/client-bundle-purity.spec.ts | 42 +++++++++- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 23 files changed, 402 insertions(+), 117 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 63d42117b6..c42ed69bb7 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: a16c022bc2d4f96bd2680a637e02a485ba4f2697 -2026-07-23-client-plugin-loading-model.zh.md: 96c6b85cd6d7bdb0cbfce4479d2cfe5b7e74f2a5 +2026-07-23-client-plugin-loading-model.md: dfa9f34276f20ffa99541db1544539d693313a2f +2026-07-23-client-plugin-loading-model.zh.md: 68fe9b912c60aceb2ecea315ed0121f9f96c1ecf diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index a16c022bc2..dfa9f34276 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -14,7 +14,7 @@ The browser client runs the same cordis plugin mechanism, so it needs the same s Conventional frontend engineering digests all dependencies at build time: one bundle, externals resolved by the bundler, nothing left to manage at runtime. Runtime module management on top of that is the unusual requirement here. The client therefore splits into two layers: the upper layer is cordis plugin loading through the same vendored Loader, and the lower layer is module-granular dependency management — `dsh-client-modules`. -The lower layer supplies four capabilities: externals (the platform list), remote arrival (same-origin external classic scripts plus lazy factory registration), versioning (content-hash revs), and hot update (invalidate/prefetch). +The lower layer supplies four capabilities: externals (the platform list), remote arrival (same-origin external classic scripts plus lazy factory registration), immutable revisioned delivery, and hot update (invalidate/prefetch). Plugin bundles are built independently outside Vite's module graph. Feeding response text into an inline script leaves the browser with a dynamic source execution: no standard source-map chain connects the network resource, generated bundle, and TypeScript/TSX source, so performance profiles and stacks stop at generated `client.js`; the module system must also buffer the complete source and split one arrival responsibility across fetch and execute transport boundaries. @@ -42,9 +42,9 @@ The vendored Loader consumes the module system through its `internal` contract The Host snapshots every built plugin artifact and concatenates its factory registration into one of two same-origin classic scripts. The parser-blocking `bootstrap` batch contains the modules row; the HTML preloads the `application` batch containing every other graph row while bootstrap executes. The module system keys in-flight transport by batch URL, so concurrent row arrivals execute one application script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization. -The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository shape `/packages///src/...`. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged; `sourcesContent` carries the source. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX. +The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository shape `/packages///src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX. -The graph retains each row's revisioned individual URL for HMR and adds content-addressed descriptors for the two startup batches. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id. +The graph retains each row's revisioned individual URL for HMR and adds content-addressed descriptors for the two startup batches. Initial row revisions are opaque process nonces rather than content hashes; they keep an exceptional initial individual request immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id. ### The loading flow, end to end @@ -54,7 +54,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber. -3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle plus its available map hashes into the row `rev`; batch revisions hash their script plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the bundle route and contributes structured index-injection rows. +3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; batch revisions hash the generated script plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the bundle route and contributes structured index-injection rows. Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. @@ -72,7 +72,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row. -How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModules.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row's script and optional map. Adding a row is ordered as synchronous artifact baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a script/map mtime or size delta, or a dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev. +How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle and optional-map stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a script/map mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev. On the browser side, the driver reloads one plugin per frame, serialized: diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 96c6b85cd6..68fe9b912c 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -14,7 +14,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac 常规前端工程在构建期消化全部依赖:单一 bundle,external 由打包器解决,运行时无物可管。在此之上再做运行时模块管理,正是这里的特殊需求。client 因此拆成两层:上层是经同一份 vendored Loader 的 cordis 插件装载,下层是模块粒度的依赖管理——`dsh-client-modules`。 -下层供给四项能力:external(平台清单)、远程到达(同源外部 classic script 加惰性工厂登记)、版本化(内容哈希 rev)、热更新(invalidate/prefetch)。 +下层供给四项能力:external(平台清单)、远程到达(同源外部 classic script 加惰性工厂登记)、不可变的版本化交付、热更新(invalidate/prefetch)。 插件 bundle 独立构建在 Vite 模块图之外。若把响应文本塞进内联 script,浏览器只能看到一次动态源码执行:网络资源、生成 bundle、TypeScript/TSX 源码之间没有标准 sourcemap 链,性能 profile 与 stack 只能落到生成后的 `client.js`;模块系统还要持有整份源码文本,并把同一项到达职责拆成 fetch 与 execute 两道传输边界。 @@ -42,9 +42,9 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点 Host 会快照每个已构建插件产物,并把其 factory registration 拼入两个同源 classic script 之一。阻塞 parser 的 `bootstrap` 批次包含 modules row;HTML 在 bootstrap 执行期间预加载包含其余全部 graph row 的 `application` 批次。模块系统按批次 URL 复用进行中的传输,因此并发 row 到达只执行一次 application 脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。 -共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形状 `/packages///src/...`。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样;`sourcesContent` 承载源码。批次生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 indexed Source Map v3 文件。Vite 壳也产出 sourcemap,使壳代码以及批量或独立重载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。 +共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形状 `/packages///src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。批次生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 indexed Source Map v3 文件。Vite 壳也产出 sourcemap,使壳代码以及批量或独立重载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。 -图为 HMR 保留每个 row 带 revision 的独立 URL,并为两个启动批次增加按内容寻址的描述。版本化脚本与 map 使用 immutable 缓存。Host 只在请求 revision 匹配时提供已快照字节;陈旧或缺失 revision 返回 404,不会在旧 URL 下别名到新字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。 +图为 HMR 保留每个 row 带 revision 的独立 URL,并为两个启动批次增加按内容寻址的描述。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证异常情况下的初始独立请求不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。版本化脚本与 map 使用 immutable 缓存。Host 只在请求 revision 匹配时提供已快照字节;陈旧或缺失 revision 返回 404,不会在旧 URL 下别名到新字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。 ### 装载流程,端到端 @@ -54,7 +54,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼 1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。 -3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 及其可用 map 共同哈希为 row `rev`;批次 revision 对脚本及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 bundle 路由并贡献结构化 index 注入行。 +3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;批次 revision 对生成的脚本及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 bundle 路由并贡献结构化 index 注入行。 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 @@ -72,7 +72,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。 -重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModules.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图每一行的脚本及可选 map 做 stat 轮询。新增图行时,顺序固定为先同步取得产物基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 +重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 与可选 map 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 浏览器侧,驱动插件每帧重载一个插件,串行执行: diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 58facd4b3e..ff17f77507 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -19,7 +19,7 @@ import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' -import { createRequire } from 'node:module' +import { createRequire, SourceMap } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -203,6 +203,23 @@ async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker }).toBe(true) } +/** Find one real source location through a served indexed map. */ +function firstMappedSource(script: string, payload: ConstructorParameters[0]): string | undefined { + const consumer = new SourceMap(payload) + const lines = script.split('\n') + for (let line = 0; line < lines.length; line++) { + const lastColumn = Math.min(lines[line]!.length, 512) + for (let column = 0; column <= lastColumn; column++) { + const entry = consumer.findEntry(line, column) + if (!('originalSource' in entry) || typeof entry.originalSource !== 'string') continue + if (entry.originalSource.startsWith('/packages/') && entry.originalSource.includes('/src/')) { + return entry.originalSource + } + } + } + return undefined +} + /** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */ async function screen(page: Page, name: string): Promise { await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) }) @@ -237,7 +254,7 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => { if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`) describe('dsh web keyless CLI smoke', () => { - it('listens on 127.0.0.1 by default', async () => { + it('serves a usable app from two immutable plugin batches', async () => { requireDist() const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-')) const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href @@ -281,7 +298,8 @@ describe('dsh web keyless CLI smoke', () => { }) await page.goto(readyUrl) await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 }) - expect([...new Set(pluginScripts)].sort()).toEqual([ + const batchPaths = [...new Set(pluginScripts)].sort() + expect(batchPaths).toEqual([ expect.stringMatching(/^\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/), expect.stringMatching(/^\/plugins\/_batch\/bootstrap\/[a-f\d]{12}\/client\.js$/), ]) @@ -289,6 +307,26 @@ describe('dsh web keyless CLI smoke', () => { 'public, max-age=31536000, immutable', 'public, max-age=31536000, immutable', ]) + for (const path of batchPaths) { + const [scriptResponse, mapResponse] = await Promise.all([ + fetch(`${readyUrl}${path}`), + fetch(`${readyUrl}${path}.map`), + ]) + expect(scriptResponse.status).toBe(200) + expect(mapResponse.status).toBe(200) + const script = await scriptResponse.text() + const payload = await mapResponse.json() as ConstructorParameters[0] + const sections = (payload as unknown as { + sections: { map: { sources?: unknown[]; sourcesContent?: unknown[] } }[] + }).sections + expect(sections.every(section => ( + Array.isArray(section.map.sources) + && Array.isArray(section.map.sourcesContent) + && section.map.sourcesContent.length === section.map.sources.length + && section.map.sourcesContent.every(source => typeof source === 'string') + ))).toBe(true) + expect(firstMappedSource(script, payload)).toMatch(/^\/packages\/.+\/src\//) + } } finally { await browser?.close() const closed = child.exitCode === null diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 529c670074..756b431f33 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.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/client-modules.md -client-modules.md: b935c7328ff2fc5e8ac5ab427a95fcae01e8df22 -client-modules.zh.md: f65b764a1d66239c40117cabf2b286a3894ce07d +client-modules.md: 8dae05be292d8d5628c59b768d84dfd97dec1ef2 +client-modules.zh.md: 3668a4a3958b3a5957f094c0f925edb120d9b8c6 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index b935c7328f..8dae05be29 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -24,7 +24,7 @@ interface WebBootEntry { id: string /** Revisioned individual endpoint used by HMR. */ url: string - /** Hash over the individual bundle and available source map. */ + /** Opaque individual-artifact revision used for HMR cache busting. */ rev: string /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] @@ -70,7 +70,7 @@ interface WebBootGraph { } ``` -Each row's `rev` hashes the individual bundle and its available source map. The bootstrap batch contains the modules row; the preloaded application batch contains every other row. Batch revisions hash the generated script and indexed source map, and the graph revision hashes both rows and batch descriptors. `immediately` marks the stage-one registration barrier; application rows share one script transport even when only some carry the mark. +Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every individual artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The bootstrap batch contains the modules row; the preloaded application batch contains every other row. Batch revisions hash the generated script and indexed source map, and the graph revision hashes both rows and batch descriptors. `immediately` marks the stage-one registration barrier; application rows share one script transport even when only some carry the mark. ## The scan @@ -86,9 +86,25 @@ Package metadata — including the negative "not a client package" verdict — i ## The service -`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes) and `clientPath(id)` the bundle's absolute path. `rebuilt(id)` is the only entry point through which bundle content reaches the graph: it re-hashes the file, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush. +```ts type-equiv +/** Filesystem baseline captured before a client artifact snapshot is read. */ +interface ClientArtifactBaseline { + /** Absolute path of the client bundle. */ + readonly path: string + /** Bundle modification time in milliseconds. */ + readonly mtimeMs: number + /** Bundle size in bytes. */ + readonly size: number + /** Source-map modification time, or null when no map was observable. */ + readonly mapMtimeMs: number | null + /** Source-map size in bytes, or null when no map was observable. */ + readonly mapSize: number | null +} +``` -In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from a synchronously captured baseline, calls `rebuilt(id)` on change, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files. +`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle/map stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes that artifact, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush. + +In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle and optional map from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files. @@ -118,6 +134,16 @@ graph(): WebBootGraph */ clientPath(id: string): string | undefined +/** + * Filesystem baseline captured before an entry's current bytes were read. + * HMR compares it with the live files when installing a watch, so a write + * between startup composition and watch installation cannot disappear into + * the watcher's initial state. + * @param id - entry id (package name). + * @returns the path and baseline, or undefined for an unknown id. + */ +artifactBaseline(id: string): ClientArtifactBaseline | undefined + /** * Re-hash one bundle (the HMR watch's registration hook — the only entry * point through which bundle content changes reach the graph). diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index f65b764a1d..3668a4a395 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -24,7 +24,7 @@ interface WebBootEntry { id: string /** Revisioned individual endpoint used by HMR. */ url: string - /** Hash over the individual bundle and available source map. */ + /** Opaque individual-artifact revision used for HMR cache busting. */ rev: string /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] @@ -70,7 +70,7 @@ interface WebBootGraph { } ``` -每一行的 `rev` 都对独立 bundle 及其可用 sourcemap 求哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输。 +每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个独立产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输。 ## 扫描 @@ -86,9 +86,25 @@ interface WebBootGraph { ## 服务 -`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回该 bundle 的绝对路径。`rebuilt(id)` 是 bundle 内容到达图的唯一入口:它对文件重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。 +```ts type-equiv +/** Filesystem baseline captured before a client artifact snapshot is read. */ +interface ClientArtifactBaseline { + /** Absolute path of the client bundle. */ + readonly path: string + /** Bundle modification time in milliseconds. */ + readonly mtimeMs: number + /** Bundle size in bytes. */ + readonly size: number + /** Source-map modification time, or null when no map was observable. */ + readonly mapMtimeMs: number | null + /** Source-map size in bytes, or null when no map was observable. */ + readonly mapSize: number | null +} +``` -开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从同步取得的基线出发,对图中每一行的 bundle 做 stat 轮询,变化时调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;模块宿主自身从不监视文件。 +`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle/map stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它只对该产物重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。 + +开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 与可选 map 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。 @@ -118,6 +134,16 @@ graph(): WebBootGraph */ clientPath(id: string): string | undefined +/** + * Filesystem baseline captured before an entry's current bytes were read. + * HMR compares it with the live files when installing a watch, so a write + * between startup composition and watch installation cannot disappear into + * the watcher's initial state. + * @param id - entry id (package name). + * @returns the path and baseline, or undefined for an unknown id. + */ +artifactBaseline(id: string): ClientArtifactBaseline | undefined + /** * Re-hash one bundle (the HMR watch's registration hook — the only entry * point through which bundle content changes reach the graph). diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index f843ddd484..4acf3d1284 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/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/hmr/README.md -README.md: 5e1d960515094d1c4f18b69ec01d49dd780d0cb5 -README.zh.md: 2c82baac20f2a68b91c25e80ee4714b6ceb2e80c +README.md: 089b3ba35780ccb7a24bc8fed10cc0a5353c9eb9 +README.zh.md: e100dde3ced0f7272e9a75bc4d0a69f6beb4d4ee diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index 5e1d960515..089b3ba357 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle. -The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The frame revision makes `invalidate` select that plugin's immutable individual URL instead of its initial batch; `prefetch` loads and registers the new factory while the old fiber still serves. The remaining sequence is `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `