Merge remote-tracking branch 'origin/master' into worktree/windows-acl-hardening-followup

This commit is contained in:
Tianyi Cui
2026-08-10 23:29:15 +08:00
215 changed files with 4310 additions and 1926 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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: be03bceb39935fafb7acc7d3a99c1fe3af686f94
README.zh.md: 10165486712fc078cdf1f4147522397a15c88955
README.md: f3ffdae3846edba6f1a1a4821adade7b6c7fce76
README.zh.md: 4f31fd743f1ddc57edc9c215a42e79a16afcdecb

View File

@@ -15,10 +15,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
| `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it |
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR |
| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR; an optional module base anchors bare package names to the installed host while relative names stay config-relative |
| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error; the optional module base has the same resolution semantics as `mountRootInclude` |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
@@ -29,7 +29,7 @@ The Loader mounts entries concurrently, so a surface can already own the termina
`cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`.
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.

View File

@@ -15,10 +15,10 @@
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
| `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 |
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 |
| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项;可选模块基准会把裸包名锚定到已安装宿主,而相对名称仍以配置目录为基准 |
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject;可选模块基准与 `mountRootInclude` 的解析语义相同 |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
@@ -29,7 +29,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
`cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。

View File

@@ -9,7 +9,7 @@
import { pathToFileURL } from 'node:url'
import { readFileSync } from 'node:fs'
import { parseEnv } from 'node:util'
import { basename, dirname, resolve } from 'node:path'
import { basename, dirname, isAbsolute, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context, type FiberState } from '@deepseek-ai/cordis'
import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
@@ -476,6 +476,8 @@ function groupedDump(
* @param ctx - context carrying an initialized Loader service.
* @param absoluteConfigPath - absolute YAML or JSON configuration path.
* @param patches - initial app and user patches, applied in order.
* @param bareModuleBaseUrl - optional installed-host base for bare package
* names; relative names continue to resolve beside the configuration file.
* @returns the created root Include entry, or `undefined` when a surface
* disposed the whole tree (taking the Loader service with it) while the
* transactional create was still settling entry lifecycle.
@@ -484,8 +486,21 @@ export async function mountRootInclude(
ctx: Context,
absoluteConfigPath: string,
patches: readonly PatchOptions[] = [],
bareModuleBaseUrl?: string,
): Promise<Entry | undefined> {
ctx.loader.builtins.include = Include
ctx.loader.builtins.include = bareModuleBaseUrl === undefined
? Include
: class HostResolvedRootInclude extends Include {
override import(name: string, getOuterStack?: () => string[]): unknown {
const specifier = isAbsolute(name) ? pathToFileURL(name).href : name
if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(specifier, getOuterStack)
const internal = this.ctx.loader.internal
/* v8 ignore next -- Node supplies the internal loader; this preserves the
original diagnostic for hypothetical embedders without it. */
if (internal === undefined) return super.import(specifier, getOuterStack)
return internal.import(specifier, bareModuleBaseUrl, {})
}
}
// `cordis:group` alongside it: a group row is how a composition gives one
// `isolate` realm to a provider and its consumers together, and an agent
// preset living outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group`
@@ -495,13 +510,14 @@ export async function mountRootInclude(
// Pinned id: the bootstrap include is app glue, not a config row, and its
// id appears in Loader failure chains — a random id would make startup
// diagnostics unstable across runs (and snapshot fixtures).
const includeConfig: Include.Config = {
path: pathToFileURL(absoluteConfigPath).href,
...patches.length > 0 ? { patches: [...patches] } : {},
}
const rootInclude: EntryOptions = {
id: 'include',
name: 'cordis:include',
config: {
path: pathToFileURL(absoluteConfigPath).href,
...patches.length > 0 ? { patches: [...patches] } : {},
},
config: includeConfig,
}
const includeId = await ctx.loader.create(rootInclude)
const loader = ctx.get('loader')
@@ -709,14 +725,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
/**
* Boot the Loader against `absoluteConfigPath` and return only after the whole
* tree settles. Entry names load through the Loader's internal module loader
* against `baseUrl` (the config directory), which may live outside
* `node_modules` reach and, unbuilt, cannot load vendored source; the
* bootstrap include is therefore statically imported and mounted as the
* `cordis:include` builtin, loading through the ambient module pipeline
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
* config-relative. The package build embeds Include while leaving Loader
* external, so the built include tree and host share one Loader peer. Loader
* tree settles. Relative entry names resolve against the config directory;
* bare package names resolve there by default or against an explicit
* `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include
* is statically imported and mounted as the `cordis:include` builtin, loading
* through the ambient module pipeline (vite/tsx/plain ESM). The package build
* embeds Include while leaving Loader external, so the built include tree and
* host share one Loader peer. Loader
* settlement rejects startup failures, which `boot` wraps after disposing the
* partial context; a missing fiber or never-activating entry is rejected by
* the final audit, {@link assertEntriesActivated}, which rethrows a plugin's
@@ -729,6 +744,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadOptionalPatches}); an empty list mounts none.
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
* @param bareModuleBaseUrl - optional installed-host base for bare package
* names; use it when the host, rather than the configuration project, owns the
* complete plugin set.
* @returns the root context once every entry has started, or as soon as a
* surface disposed the tree while startup was still in flight.
* @throws a labelled error after disposing the partial context — `host
@@ -740,6 +758,7 @@ export async function boot(
absoluteConfigPath: string,
patches?: PatchOptions[],
prepare?: (ctx: Context) => Promise<void> | void,
bareModuleBaseUrl?: string,
): Promise<Context> {
const ctx = new Context()
// Two failure labels: `prepare` runs before any config-tree entry mounts,
@@ -751,7 +770,7 @@ export async function boot(
await ctx.plugin(Loader)
await prepare?.(ctx)
stage = 'plugin tree failed to load'
await mountRootInclude(ctx, absoluteConfigPath, patches)
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
// A surface can finish and dispose the whole tree while startup is still
// in flight, before the last entry settles. The Loader service goes with
// it, and the activation audit describes a live tree — reading `ctx.loader`

View File

@@ -1,6 +1,7 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
@@ -557,6 +558,73 @@ describe('boot', () => {
}
})
it('can resolve bare plugins from the harness when the config project shadows their package name', async () => {
const dir = tmp()
const harness = tmp()
const absolutePlugin = join(dir, 'absolute.mjs')
const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt')
const harnessPlugin = join(harness, 'node_modules', '@deepseek-ai', 'dsh-system-prompt')
mkdirSync(shadow, { recursive: true })
mkdirSync(harnessPlugin, { recursive: true })
writeFileSync(join(shadow, 'package.json'), JSON.stringify({
name: '@deepseek-ai/dsh-system-prompt',
type: 'module',
exports: './index.mjs',
}))
writeFileSync(join(shadow, 'index.mjs'), [
'export function apply(ctx) {',
' ctx.provide("shadowPluginLoaded", true)',
'}',
'',
].join('\n'))
writeFileSync(join(harnessPlugin, 'package.json'), JSON.stringify({
name: '@deepseek-ai/dsh-system-prompt',
type: 'module',
exports: './index.mjs',
}))
writeFileSync(join(harnessPlugin, 'index.mjs'), [
'export function apply(ctx) {',
' ctx.provide("harnessPluginLoaded", true)',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n')
writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n')
const entries = [
'- id: prompt',
" name: '@deepseek-ai/dsh-system-prompt'",
'- id: relative',
" name: './relative.mjs'",
]
const configOwnedPath = join(dir, 'config-owned.cordis.yml')
writeFileSync(configOwnedPath, [...entries, ''].join('\n'))
const hostOwnedPath = join(dir, 'host-owned.cordis.yml')
writeFileSync(hostOwnedPath, [
...entries,
'- id: absolute',
` name: ${JSON.stringify(absolutePlugin)}`,
'',
].join('\n'))
const configOwned = await boot(NAME, configOwnedPath)
try {
expect(configOwned.get('shadowPluginLoaded')).toBe(true)
expect(configOwned.get('systemPrompt')).toBeUndefined()
expect(configOwned.get('relativePluginLoaded')).toBe(true)
} finally {
await configOwned.fiber.dispose()
}
const harnessBaseUrl = pathToFileURL(join(harness, 'entry.mjs')).href
const ctx = await boot(NAME, hostOwnedPath, undefined, undefined, harnessBaseUrl)
try {
expect(ctx.get('harnessPluginLoaded')).toBe(true)
expect(ctx.get('shadowPluginLoaded')).toBeUndefined()
expect(ctx.get('relativePluginLoaded')).toBe(true)
expect(ctx.get('absolutePluginLoaded')).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('runs host preparation before the Loader tree mounts', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')

View File

@@ -91,9 +91,9 @@
# assembly fact of dsh-web-app, never user config), mounts the
# frontend-static fallback owner, registers the web-surface prompt
# section and bash runtime variables, and prints the URL line. `dsh web`
# patches mode/lanAddresses over these defaults; complete-prompt overlays
# set surfaceContext false to suppress every model- and shell-visible Web
# runtime contribution.
# patches mode/lanAddresses over these defaults. A complete agent-preset
# persona suppresses the prompt section for that agent while retaining
# these host-owned shell variables.
- id: web-runtime
name: '@deepseek-ai/dsh-web-app'
config:

View File

@@ -42,7 +42,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.',
presetMinimalName: 'Minimal mode',
presetMinimalDescription:
'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.',
'Two-tool coding agent with persistent bash and str_replace_editor.',
presetCordisName: 'Creator mode',
presetCordisDescription:
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
@@ -103,7 +103,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
presetCodeName: '代码模式',
presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。',
presetMinimalName: '极简模式',
presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。',
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
presetCordisName: '创造模式',
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
duplicate: '复制',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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: 13b05bfcd19212ade42f22ece455871d022e6260
README.zh.md: 0f9e7a2358134018975db1bc3c6b7206a274b3ec
README.md: cedda783d549633f5be9765a9a074e968d99500d
README.zh.md: 41729cdd1cfe6ebbd86f38c15bab5c50bd6ff7d2

View File

@@ -16,19 +16,19 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. A `complete: true` section becomes the exact complete prompt after the assembly waterfall; more than one effective complete section rejects assembly. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores an effective complete section as the sole prompt section. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects for multiple complete sections, when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
### Live events
`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts.
`system-prompt/assemble` is authoritative for ordinary sections; a complete section is the final prompt constraint applied after the waterfall. Listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts.
### Key types
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame.
- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
- `PromptSection` — `{ name, order, text, complete? }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. One effective `complete` section suppresses all other sections after cooperative assembly.
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
@@ -39,7 +39,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller before any complete-section constraint is enforced.
Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
@@ -49,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple
#### What the model sees
By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas.
By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete; that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain.
##### Harness identity

View File

@@ -16,21 +16,21 @@
### 公开 API
- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。
- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。一个 `complete: true` 段会在组装 waterfall 之后成为精确的完整提示词;有效 complete 段超过一个时,组装会被拒绝。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,之后将一个有效的 complete 段恢复为唯一的提示词段落。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。存在多个 complete 段、已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
<a id="live-events"></a>
### 实时事件
`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。
`system-prompt/assemble` 对普通段落具有权威性;complete 段是在 waterfall 之后应用的最终提示词约束。替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。
### 关键类型
- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。
- `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。
- `PromptSection`:`{ name, order, text, complete? }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。协作式组装完成后,一个有效的 `complete` 段会抑制其他所有段落。
- `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。
- `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。
@@ -41,7 +41,7 @@
- 段提供方:工具包拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。
- 变量提供方:agent loop(智能体循环)注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。
- 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。
- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。
- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果,之后再实施 complete 段约束。
设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。
@@ -51,7 +51,7 @@
#### 模型看到的内容
默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。
默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete;此时,该确切段落会成为完整的系统提示词,而 waterfall 得到的上下文、工具和变量保持不变。
##### Harness 身份

View File

@@ -21,7 +21,9 @@ declare module '@deepseek-ai/cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* A supplied signal controls only this explicit assembly request and must not
* be retained to control later turns.
* be retained to control later turns. A registered complete section is
* restored after this waterfall, so listeners cannot add to or replace
* that scope's system prompt.
* @param assembly - the mutable assembly built from registered providers.
* @param context - the caller's per-assembly context.
* @mode waterfall
@@ -63,6 +65,13 @@ export interface PromptSection {
* interpolated later, by {@link renderPrompt}.
*/
readonly text: string | ((context: AssembleContext) => string)
/**
* Treat this contribution as the complete system prompt. Assembly still
* runs the cooperative waterfall so tools, contexts, and variables can be
* resolved, then restores this exact section as the sole prompt section.
* More than one effective complete section makes assembly fail.
*/
readonly complete?: boolean
}
/** Dynamic model context materialized as a durable user-role snapshot. */
@@ -428,9 +437,11 @@ export class SystemPrompt extends Service {
/**
* Assemble global and scoped providers, detach tool parameters, apply
* canonical ordering, then run the assembly waterfall. Scoped sections and
* variables shadow globals; the returned waterfall value is authoritative.
* variables shadow globals. The returned waterfall value is authoritative
* except that an effective complete section is restored afterwards as the
* sole prompt section.
* @param context - the optional scope and plugin-defined assembly fields.
* @returns the authoritative post-waterfall assembly.
* @returns the post-waterfall assembly with any complete prompt enforced.
*/
// Keep configuration failures on the declared asynchronous error path.
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
@@ -467,13 +478,23 @@ export class SystemPrompt extends Service {
collected.push(...schemas)
for (const name of acceptedKnownNames) knownNames.add(name)
}
const assembly: PromptAssembly = {
sections: [...sectionByName.values()]
.sort((a, b) => a.order - b.order)
.map(section => ({
const sectionDefinitions = [...sectionByName.values()].sort((a, b) => a.order - b.order)
const completeSections = sectionDefinitions.filter(section => section.complete === true)
if (completeSections.length > 1) {
throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`)
}
let completeSection: AssembledSection | undefined
const sections = sectionDefinitions
.map((section) => {
const assembled = {
name: section.name,
text: typeof section.text === 'function' ? section.text(context) : section.text,
})),
}
if (section.complete === true) completeSection = { ...assembled }
return assembled
})
const assembly: PromptAssembly = {
sections,
contexts: [...contextByName.values()]
.sort((a, b) => a.order - b.order)
.map(entry => ({
@@ -483,10 +504,12 @@ export class SystemPrompt extends Service {
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
return this.ctx.waterfall(
const transformed = await this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
)
if (completeSection === undefined) return transformed
return { ...transformed, sections: [completeSection] }
}
}

View File

@@ -264,6 +264,34 @@ describe('SystemPrompt', () => {
expect(assembly.sections).toHaveLength(0)
})
it('restores one complete section after the assembly waterfall', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'complete', order: 10, text: 'Exact prompt.', complete: true })
ctx.systemPrompt.section({ name: 'extra', order: 20, text: 'extra' })
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
const complete = assembly.sections.find(section => section.name === 'complete')
if (complete === undefined) throw new Error('complete section missing before waterfall')
complete.text = 'mutated'
assembly.sections.push({ name: 'late', text: 'late' })
return next()
}, { prepend: true })
expect((await ctx.systemPrompt.assemble()).sections).toEqual([
{ name: 'complete', text: 'Exact prompt.' },
])
})
it('rejects multiple effective complete sections', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'first', order: 10, text: 'first', complete: true })
ctx.systemPrompt.section({ name: 'second', order: 20, text: 'second', complete: true })
await expect(ctx.systemPrompt.assemble())
.rejects.toThrow('multiple complete prompt sections are active: "first", "second"')
})
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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/jsonrpc-demo/README.md
README.md: fff8e78698cd3d6320606084ef5c533be7c52633
README.zh.md: 75382b97ea1837cf1415e8a7f5004206596e168c
README.md: 40ced3ee1fe2d3eac69b82501d417130267d7634
README.zh.md: 451bdf7428f8265750082af240418d4653bb8595

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published bin is `dsh-jsonrpc-agent`, and `lib/bin.js` also ships as the `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) used by the Python SDK.
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative.
## Config discovery

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 bin 名为 `dsh-jsonrpc-agent`,`lib/bin.js` 还会作为 Python SDK 使用的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)交付。
只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。
## 配置发现

View File

@@ -22,6 +22,10 @@
"types": "./lib/types/bin.d.ts",
"default": "./lib/bin.js"
},
"./packaged-bin": {
"types": "./lib/types/packaged-bin.d.ts",
"default": "./lib/packaged-bin.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -29,6 +33,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/packaged-bin.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",

View File

@@ -1,52 +1,11 @@
#!/usr/bin/env node
/**
* Boots an external `cordis.yml`; its `@deepseek-ai/dsh-jsonrpc` entry serves
* newline-delimited JSON-RPC on stdio. `$DSH_CORDIS_CONFIG` wins over `argv[2]`;
* empty or missing paths exit 1, with no default config or `DSH_SNAPSHOT` mode.
* App-boot owns env loading, Loader guards, and settled-tree startup.
* stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130.
* Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames.
* Generic JSON-RPC agent bin. External configurations own their bare plugin
* packages; the packaged runtime uses `packaged-bin.ts` instead.
*
* @module @deepseek-ai/dsh-jsonrpc-demo/bin
*/
import { existsSync } from 'node:fs'
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import { runJsonrpcAgent } from './runner.ts'
const NAME = 'dsh-jsonrpc-agent'
/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */
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} <path/to/cordis.yml> (or set DSH_CORDIS_CONFIG=<path>, which wins); the config is required — there is no built-in fallback\n`,
)
process.exit(1)
}
const ctx = await boot(NAME, configPath)
let exiting = false
async function disposeAndExit(code: number): Promise<void> {
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 */
await runJsonrpcAgent()

View File

@@ -1,7 +1,8 @@
/**
* Bin-only app package: `bin.ts` 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-jsonrpc} serving plugin.
* Bin-only app package: its generic and packaged entries discover an external
* `cordis.yml` and own process exit. This module exports no composition plugin;
* the config chooses whether to load the
* {@link @deepseek-ai/dsh-jsonrpc} serving plugin.
*
* @module @deepseek-ai/dsh-jsonrpc-demo
*/

View File

@@ -0,0 +1,12 @@
#!/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-jsonrpc-demo/packaged-bin
*/
import { runJsonrpcAgent } from './runner.ts'
/* v8 ignore next -- exercised through the built Python runtime carriers */
await runJsonrpcAgent(import.meta.url)

View File

@@ -0,0 +1,55 @@
/**
* Shared process lifecycle for the generic and closed-runtime JSON-RPC bins.
*
* @module @deepseek-ai/dsh-jsonrpc-demo/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 - optional installed-runtime base for bare plugins;
* omit it when the configuration project owns its plugin packages.
* @returns after process handlers are installed; process lifetime then belongs
* to stdin and signal events.
*/
export async function runJsonrpcAgent(bareModuleBaseUrl?: string): Promise<void> {
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} <path/to/cordis.yml> (or set DSH_CORDIS_CONFIG=<path>, 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<void> {
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 */

View File

@@ -1,15 +1,21 @@
import { defineConfig } from 'tsdown'
/**
* Build the doc-only module and CLI entry; `tsc -b` supplies declarations.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})
/** 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/bin.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,
},
])

View File

@@ -354,11 +354,11 @@ describe('agentPreset.select', () => {
})
it('records the switch in the log, and the list reads it back', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
const { api, ctx } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' }))
await api.agentPresets.select(
request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' }))
request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' }))
// The header is written once at creation, so the switch lives in the log —
// this is what a restart replays and what every projection resolves from.
@@ -366,11 +366,11 @@ describe('agentPreset.select', () => {
const session = ctx.sessions.get(SessionId('sel-log'))
if (session === undefined) throw new Error('unreachable')
expect(session.header.agentPreset).toBe('standard')
expect(resolveSessionPreset(session)).toBe('core-web')
expect(resolveSessionPreset(session)).toBe('minimal')
const listed = await api.sessions.list(request({}))
if (!listed.result.ok) throw new Error('unreachable')
expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset)
.toBe('core-web')
.toBe('minimal')
})
it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
@@ -405,14 +405,14 @@ describe('agentPreset.select', () => {
})
it('serializes two concurrent selects on one session', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
const { api, ctx } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))
// Both pass the blank check; unserialized, the second unmount finds no
// record because the first already removed it, and two compositions end up
// in one agent layer. The client's busy flag is not enforcement.
const [first, second] = await Promise.all([
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })),
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })),
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
])
@@ -635,7 +635,7 @@ describe('skills over the layered host registry', () => {
})
it('resolves a cold session to its recorded preset standing key', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
const { api, ctx } = await harness(['standard', 'minimal'])
const seen: unknown[] = []
ctx.provide('skills', {
list: (options: { scope?: unknown }) => {
@@ -643,12 +643,12 @@ describe('skills over the layered host registry', () => {
return Promise.resolve([])
},
} as never)
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } })
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } })
const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
expect(seen).toEqual([standingKeys.get('core-web')])
expect(seen).toEqual([standingKeys.get('minimal')])
})
it('serves the global view when the roster no longer supplies the recorded preset', async () => {
@@ -671,8 +671,8 @@ describe('skills over the layered host registry', () => {
describe('session.history presenter scope', () => {
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
const { api } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' }))
const { api } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' }))
// Cold: creation registered a live agent in this harness, so simulate the
// cold path by asking for a session only persistence knows... the harness
// has no persistence, so read the live one and assert no roster query.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
README.md: 632fc7828313a932512cb59a924050005067a008
README.zh.md: 41dfab1af81149a221cb333a5613ab0a2d899899
README.md: e31dc8e666096baf6fd6b2cf9407110c7678c6e1
README.zh.md: cf7e24bb0f256623beec1b86c70473a70be03cb5

View File

@@ -73,7 +73,7 @@ A preset may publish display text in an optional `preset.yml` beside its composi
```yaml
name: 极简模式
description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。
description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
```
It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load.

View File

@@ -73,7 +73,7 @@ preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本:
```yaml
name: 极简模式
description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。
description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
```
它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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/persona/README.md
README.md: 789776b32d907f7d217accccbca5508f88de0ed1
README.zh.md: 4e28d75bbd4fd22b77a0fa3b18c5f19df08588d8
README.md: 742141e65fa8d50b89e6b74e6d21aa8c5bfe98cd
README.zh.md: add106adb5b81e45d8c6929a9a0f98b5c0072a01

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The agent persona as a composable row. One config field, one prompt section.
The agent persona as a composable row. It can either shadow the deployment persona or own the complete system prompt.
[`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row.
@@ -15,8 +15,9 @@ Mounting this row outside an agent scope collides with the registry's own `deplo
| Field | Default | Meaning |
|---|---|---|
| `text` | required | Persona prose rendered as the `deployment:persona` section |
| `complete` | `false` | Restore this persona after assembly as the only system-prompt section |
`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render.
`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. With `complete: true`, assembly still resolves contexts, tools, variables, and cooperative listeners, then the prompt registry restores this exact persona as the sole section; no identity, tool guidance, or listener can append prompt text.
## Model Experience
@@ -24,11 +25,11 @@ Mounting this row outside an agent scope collides with the registry's own `deplo
#### What the model sees
The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured.
The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. In complete mode, the model sees only this rendered section as its system prompt.
#### Token effect
Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing.
Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. Complete mode removes every other system-prompt token for that agent.
#### KV Cache effect

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
把 agent(智能体)人设做成一个可组装的行:一个配置字段,一个提示词段落。
把 agent(智能体)人设做成一个可组装的行:它既可以遮蔽部署级人设,也可以拥有完整系统提示词。
[`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。
@@ -15,8 +15,9 @@
| 字段 | 默认值 | 含义 |
|---|---|---|
| `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 |
| `complete` | `false` | 组装后将此人设恢复为唯一的系统提示词段落 |
`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。
`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。启用 `complete: true` 时,组装仍会解析上下文、工具、变量和协作式监听器,之后提示词注册表将这份确切人设恢复为唯一段落;身份、工具引导或监听器都无法追加提示词文本。
## Model Experience
@@ -24,11 +25,11 @@
#### What the model sees
位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。
位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。在完整模式下,模型只会看到这个渲染后的段落作为系统提示词。
#### Token effect
对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。
对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。完整模式会移除该 agent 的其他所有系统提示词 token。
#### KV Cache effect

View File

@@ -38,23 +38,27 @@ export interface Config {
* variables. Empty text drops the section at render, matching the registry.
*/
text: string
/** Make this persona the complete system prompt, suppressing every other section. */
complete?: boolean
}
/** Runtime schema for the persona row. */
export const Config: z<Config> = z.object({
text: z.string().required(),
complete: z.boolean().default(false),
})
/**
* Register the persona section for the mounting context's scope.
* @param ctx - an agent scope context; an unscoped context collides with the
* prompt registry's own persona registration and rejects.
* @param config - the persona text.
* @param config - the persona text and complete-prompt policy.
*/
export function apply(ctx: Context, config: Config): void {
ctx.effect(() => ctx.systemPrompt.section({
name: PERSONA_SECTION,
order: PERSONA_ORDER,
text: config.text,
...(config.complete ? { complete: true } : {}),
}), 'persona.section()')
}

View File

@@ -16,7 +16,8 @@ export const inject = ['invariants']
/**
* No runtime invariant: this row owns no event stream or mutable runtime data — it registers one
* prompt section and the prompt registry owns section identity, shadowing, and disposal.
* prompt section and the prompt registry owns identity, complete-prompt enforcement, shadowing,
* and disposal.
*/
const install: InvariantInstaller = () => {}

View File

@@ -85,4 +85,21 @@ describe('the persona row', () => {
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key })))
.toContain('You run on deepseek-v4-pro.')
})
it('makes a complete persona the exact prompt after every other contribution', async () => {
const ctx = await harness('deployment identity')
const key: ScopeKey = { agent: 'a1' }
const scope = createScope(ctx, key)
ctx.systemPrompt.section({ name: 'global:extra', order: 100, text: 'global guidance' })
await scope.ctx.plugin(Persona, { text: 'Only this.', complete: true })
scope.ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
assembly.sections.push({ name: 'late:extra', text: 'late guidance' })
return next()
}, { prepend: true })
const assembly = await ctx.systemPrompt.assemble({ scope: key })
expect(assembly.sections).toEqual([{ name: PERSONA_SECTION, text: 'Only this.' }])
expect(renderPrompt(assembly)).toBe('Only this.')
})
})

View File

@@ -26,6 +26,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/runner.js",
"lib/types-*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",

View File

@@ -1114,7 +1114,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals; the returned waterfall value is authoritative.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the authoritative post-waterfall assembly.\n */',
jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals. The returned waterfall value is authoritative\n * except that an effective complete section is restored afterwards as the\n * sole prompt section.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the post-waterfall assembly with any complete prompt enforced.\n */',
},
],
},
@@ -1610,7 +1610,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'system-prompt/assemble',
mode: 'waterfall',
signature: '\'system-prompt/assemble\'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>',
jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */',
jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns. A registered complete section is\n * restored after this waterfall, so listeners cannot add to or replace\n * that scope\'s system prompt.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */',
summary: 'Expert waterfall over the assembled sections, contexts, tools, and variables.',
},
{
@@ -2421,7 +2421,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}',
},
{
name: 'ProviderRequestId',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d
README.zh.md: 648a160be5f1c3dcbe66a867a273a3df610dbc0a
README.md: 209f1e9526ff4a01af6f4c96955068de4b2b06c0
README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14

View File

@@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p
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.
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
The driver applies the seam's [delegated policy](../subagent/README.md#delegated-policy) through the shared child-agent helpers: it captures the parent's explicit sandbox override and the `'never'` approval pin before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [delegation-policy decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
## Cancellation and ownership

View File

@@ -20,7 +20,7 @@
该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
## 取消与所有权

View File

@@ -28,22 +28,12 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-sandbox-policy": {
"optional": true
},
"@deepseek-ai/dsh-user-approval": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "^1.0.4",
"@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5",

View File

@@ -17,8 +17,10 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import {
appendDelegatedPolicyOverrides,
applyChildComposition,
assertSubagentMaxDepth,
captureDelegatedPolicyOverrides,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
@@ -30,11 +32,6 @@ import type {
SubagentRun,
SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
// to the policy services when composed — the driver consumes both
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import {
attachStructuredRuntime,
type StructuredAttachment,
@@ -111,20 +108,11 @@ export async function startInProcessRun(
// Capture before the first await: a later parent switch belongs to the
// parent's future.
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
const inherited = captureDelegatedPolicyOverrides(parent)
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
// Inherited overrides land on the child's own log, so its effective policy
// is reconstructable from that log alone.
const childSession = (childCtx.agent as Agent).session
if (inheritedMode !== undefined) {
childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
}
if (inheritedPolicy !== undefined) {
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
}
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)
applyChildComposition(childCtx, parent, {
persona: request.persona,
toolFilter: request.toolFilter,

View File

@@ -1,4 +1,7 @@
/** Policy inheritance through child session events appended before publication. */
/**
* Delegation policy through child session events appended before publication:
* the parent's sandbox override plus the pinned `approval/policy: never`.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
@@ -13,7 +16,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
@@ -76,12 +79,13 @@ function toolResultTexts(agent: Agent): string[] {
}
describe('in-process policy inheritance', () => {
it('records parent overrides before publishing a spawn child', async () => {
it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'spawn-blocked.txt')
setSandboxMode(parent.session, 'read-only')
setApprovalPolicy(parent.session, 'never')
// No parent approval override: the child pin must not depend on one.
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
const parentLogLength = parent.session.events.length
script.push(
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
@@ -120,7 +124,10 @@ describe('in-process policy inheritance', () => {
.join('\n')
expect(contextText).toContain('Current DSH file policy: read-only')
expect(contextText).toContain('Approval prompts are disabled')
// The statement rides runtime context; the system prompt stays uniform.
expect(contextText).toContain('You are a delegated subagent')
expect(request.data.header.system).not.toContain('Approval prompts are disabled')
expect(request.data.header.system).not.toContain('You are a delegated subagent')
expect(parent.session.events).toHaveLength(parentLogLength)
} finally {
await run.dispose()
@@ -179,7 +186,7 @@ describe('in-process policy inheritance', () => {
}
})
it('does not freeze deployment defaults into an unswitched child', async () => {
it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => {
const script: Script = []
const { parent } = await setupWalled(script)
const allowed = join(workspace, 'default-allowed.txt')
@@ -193,12 +200,56 @@ describe('in-process policy inheritance', () => {
await run.result
const child = run.localAgent as Agent
expect(await readFile(allowed, 'utf8')).toBe('fine')
expect(child.session.events.some(
event => event.type === 'sandbox/mode' || event.type === 'approval/policy',
)).toBe(false)
expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false)
expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([
{ seq: 0, data: { policy: 'never', source: 'delegation' } },
])
expect(child.session.firstLiveSeq).toBe(0)
} finally {
await run.dispose()
}
})
it('rejects a child escalation deterministically even when an answerer would allow it', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
// A granting answerer proves the pin resolves before any answerer runs.
let consulted = false
ctx.on('approval/request', () => {
consulted = true
return Promise.resolve('allowed-once' as const)
})
const blocked = join(workspace, 'escalation-blocked.txt')
setSandboxMode(parent.session, 'read-only')
script.push(
toolCallResponse('write', 'write', {
file_path: blocked,
content: 'escaped',
sandbox_permissions: 'workspace-write',
justification: 'test escalation from a delegated child',
}),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
await run.result
const child = run.localAgent as Agent
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(consulted).toBe(false)
expect(toolResultTexts(child).join('\n'))
.toContain('the user rejected escalating this operation to "workspace-write"')
const asked = child.session.events.find(
(event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked',
)
const decided = child.session.events.find(
(event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided',
)
expect(asked?.data.toolName).toBe('write')
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
} finally {
await run.dispose()
}
})
})

View File

@@ -247,10 +247,10 @@ describe('in-process structured output', () => {
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
// Exactly one model request and one user message: no nudge turn exists.
// Exactly one model request and one caller-supplied user message: no nudge turn exists.
expect(adapter.requests.length).toBe(1)
const child = ctx.agents.get(run.id)!
expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1)
expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1)
await run.dispose()
})

View File

@@ -32,14 +32,8 @@
{
"path": "../../core/tools"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../support/invariants"
},
{
"path": "../../interaction/user-approval"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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: b69428e4af7d1f53adb22be1e59beb79c054713f
README.zh.md: 9f5eb5f1c508135c21bf3923f60f4f872de8e9f6
README.md: 42a10adeccb8e299e25ff0a5e0a918ef09b79617
README.zh.md: 34e2ed6c1ca23df9b3158f3caea10cd19bafa841

View File

@@ -56,6 +56,10 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority.
## Delegated policy
Both in-process delegation paths fix the child's permission scope at the delegation boundary through the shared child-agent helpers. `captureDelegatedPolicyOverrides(parent)` snapshots the parent session's explicit sandbox override (`sandboxPolicy.overrideOf()`) and pins the child's approval policy to `'never'` whenever the approval capability is composed — regardless of the parent's own policy — so a delegated child acts only within its inherited sandbox scope and every ask (for example a `sandbox_permissions` escalation) is rejected deterministically instead of waiting on a prompt no one is watching (both services are optional `ctx.get` consumers). `appendDelegatedPolicyOverrides()` writes each value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state and the child's effective policy stays reconstructable from its log alone. The sandbox deployment default is never copied: an unswitched parent stamps no `sandbox/mode` and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. Every in-process child also receives a scoped runtime-context statement (`subagent:delegation`) telling it the scope is fixed and that a task needing wider access ends with a reported limitation, not retries. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) delegation-policy Agent Notes.
## One-shot ownership and lifecycle
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
@@ -96,11 +100,25 @@ Continuable Activations await a best-effort final session flush without treating
## Model Experience
Indirectly, through `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. The first owns delegation schemas, the second owns parent continuation and discovery, and the third contributes `report` only to continuable child scopes.
### Child delegation-scope statement
#### What the model sees
Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences; parent-side rendering stays with `dsh-tool-subagent` (delegation schemas), `dsh-tool-subagent-control` (continuation and discovery), and `dsh-tool-subagent-report` (the child-scoped `report`).
##### The delegation-scope statement
```markdown
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.
```
#### Token effect
One fixed statement in each child's runtime-context snapshot; none in the parent's requests.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
Prefix-stable within a child: the statement never changes during the child's lifetime, so it is written once into the first runtime-context snapshot. Parent-side, no direct invalidation; the named tool consumers own any request-prefix changes.
## Known Limitations and Deferred Work

View File

@@ -56,6 +56,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。
## 委派策略
两条进程内委派路径都会通过共享的子 agent 辅助函数,在委派边界固定子 agent 的权限范围。`captureDelegatedPolicyOverrides(parent)` 对父会话的显式沙箱覆盖项(`sandboxPolicy.overrideOf()`)获取快照,并在审批能力已组合时把子 agent 的审批策略钉定为 `'never'`——无论父级自身的策略是什么——因此被委派的子 agent 只在其继承的沙箱范围内行动,每次请求(例如一次 `sandbox_permissions` 升级)都被确定性拒绝,而不是等待一个无人在看的提示(这两个服务都是可选的 `ctx.get` 消费方)。`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,而子 agent 的生效策略始终可以仅凭其日志重建。沙箱的部署默认值绝不复制:未切换的父级不会记录 `sandbox/mode`,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。每个进程内子 agent 还会收到一条作用域内的运行时上下文声明(`subagent:delegation`),告知其权限范围已固定,需要更宽访问的任务应以上报限制收尾,而不是重试。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇委派策略 Agent Note。
## 一次性所有权与生命周期
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。
@@ -96,11 +100,25 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 模型体验
通过 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 间接产生影响。第一个工具负责委派 schema,第二个负责父级延续和发现,第三个只向可继续子级作用域贡献 `report`。
### 子级委派范围声明
#### 模型看到的内容
每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后;父级侧的渲染仍归 `dsh-tool-subagent`(委派 schema)、`dsh-tool-subagent-control`(延续与发现)和 `dsh-tool-subagent-report`(子级作用域的 `report`)所有。
##### 委派范围声明
```markdown
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.
```
#### Token 影响
每个子 agent 的运行时上下文快照中一条固定声明;父级请求中没有任何新增。
#### KV Cache 影响
不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。
子级内部前缀稳定:该声明在子 agent 生命周期内绝不变化,因此只写入第一份运行时上下文快照一次。父级侧不会直接使缓存失效;具名工具消费方共同负责请求前缀的任何变化。
## 已知限制与暂缓事项

View File

@@ -38,6 +38,8 @@
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
@@ -45,12 +47,19 @@
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-agent-presets": {
"optional": true
},
"@deepseek-ai/dsh-sandbox": {
"optional": true
},
"@deepseek-ai/dsh-sandbox-policy": {
"optional": true
},
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
@@ -62,6 +71,9 @@
},
"@deepseek-ai/dsh-tasks": {
"optional": true
},
"@deepseek-ai/dsh-user-approval": {
"optional": true
}
},
"devDependencies": {
@@ -70,6 +82,8 @@
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
@@ -79,6 +93,7 @@
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,17 +1,24 @@
/**
* Shared in-process child composition: the delegation-depth budget, the
* durable session metadata, the resolved child `AgentOptions`, and the scoped
* setup a child agent needs. Both the one-shot provider driver and the
* continuation manager compose children this way, so depth accounting and
* lineage stamping have one home.
* durable session metadata, the resolved child `AgentOptions`, the delegated
* policy seed, and the scoped setup a child agent needs. Both the one-shot
* provider driver and the continuation manager compose children this way, so
* depth accounting, lineage stamping, and delegation policy have one home.
*
* @module @deepseek-ai/dsh-subagent/child-agent
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
// to the policy services when composed — delegation consumes both
// opportunistically (the documented `ctx.get` pattern), never as a hard dep —
// and merge the `sandbox/mode` / `approval/policy` session-event payloads.
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
// Type-only: make `ctx.get('agentPresets')` resolve to the preset roster when
// composed — a child inherits its parent's composition opportunistically (the
// documented `ctx.get` pattern), never as a hard dep. A rosterless deployment
@@ -121,21 +128,34 @@ export interface ChildComposition {
}
/**
* Compose one child inside its creation window: join its parent's preset, then
* apply the child's own shadowing persona section and tool restriction, both
* owned by the child's scope and therefore invisible to its parent and
* siblings.
* Model-facing delegation-scope statement for every in-process child. A
* runtime-context contribution rather than a system-prompt section, so the
* deployment's system prompt stays uniform across parents and children.
*/
export const SUBAGENT_DELEGATION_CONTEXT
= '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.'
/**
* Compose one child inside its creation window: join its parent's preset,
* register the fixed delegation-scope statement, then apply the child's own
* shadowing persona section and tool restriction, all owned by the child's
* scope and therefore invisible to its parent and siblings. Creation and cold
* resume both pass through here.
*
* The join comes first and the child's own registrations second, which is the
* order the layering already implies — the nearest scope wins a name, and a
* per-child restriction intersects with everything its chain admits — but
* stating it here keeps the two steps from being read as independent.
*
* Both steps live in ONE call because a child composed with only the second is
* exactly the defect this function exists to prevent: with every model-facing
* row on the agent plane, a child that joins no preset sees an empty tool
* registry and none of its parent's prompt sections. Taking the parent as a
* parameter is what makes that omission unrepresentable at the call sites.
* The join and the per-child registrations live in ONE call because a child
* composed without the join is exactly the defect this function exists to
* prevent: with every model-facing row on the agent plane, a child that joins
* no preset sees an empty tool registry and none of its parent's prompt
* sections. Taking the parent as a parameter is what makes that omission
* unrepresentable at the call sites.
* @param childCtx - the child agent's scoped creation context.
* @param parent - the delegating parent whose composition the child joins.
* @param composition - the per-child persona and tool filter to install.
@@ -146,12 +166,64 @@ export function applyChildComposition(
composition: ChildComposition,
): void {
childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx)
// Order 120: after the sandbox:policy (110) and approval:policy (115) sentences.
childCtx.systemPrompt.context({ name: 'subagent:delegation', order: 120, text: SUBAGENT_DELEGATION_CONTEXT })
if (composition.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona })
}
if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter)
}
/** Policy seeded onto a child session's log at the delegation boundary. */
export interface DelegatedPolicyOverrides {
/** The parent session's explicit sandbox-mode override, or `undefined` without one. */
readonly sandboxMode: SandboxMode | undefined
/**
* `'never'` whenever the approval capability is composed, `undefined`
* otherwise: a delegated child acts only within the sandbox scope fixed at
* delegation, so its asks are rejected deterministically.
*/
readonly approvalPolicy: 'never' | undefined
}
/**
* Capture the policy to seed into one delegation. Call synchronously before
* the child start's first await: a later parent switch belongs to the
* parent's future, not to this child. Only the parent session's explicit
* sandbox override is captured — never deployment defaults or one-shot
* grants — and the approval policy is pinned to `'never'` regardless of the
* parent's own policy.
* @param parent - the delegating parent agent.
* @returns the sandbox override (or `undefined` without one) and the approval pin.
*/
export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {
return {
sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),
approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never',
}
}
/**
* Append the captured delegation policy onto the child's own log as
* `source: 'delegation'` events inside the unpublished creation window, so the
* child's effective policy is reconstructable from its log alone. Appends land
* after any fork seed, so fresh policy wins stale seed state; later child
* switches still win over these events.
* @param childSession - the unpublished child's session.
* @param overrides - the policy captured at delegation.
*/
export function appendDelegatedPolicyOverrides(
childSession: Session,
overrides: DelegatedPolicyOverrides,
): void {
if (overrides.sandboxMode !== undefined) {
childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' })
}
if (overrides.approvalPolicy !== undefined) {
childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' })
}
}
/** Identity and lineage inputs shared by every in-process child creation. */
export interface ChildCreateInputs {
/** The child's reserved session id. */

View File

@@ -32,11 +32,14 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
import type { SubagentDescriptorData } from './descriptor.ts'
import {
appendDelegatedPolicyOverrides,
applyChildComposition,
captureDelegatedPolicyOverrides,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
} from './child-agent.ts'
import type { DelegatedPolicyOverrides } from './child-agent.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { seedDescriptorTurn } from './descriptor-seed.ts'
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
@@ -203,8 +206,17 @@ interface MaterializeInputs {
childId: SessionId
provider: string
parent: Agent
/** Creation inputs; absent for a cold resume, which loads the persisted session. */
create?: { seed: readonly SessionEvent[]; meta: NonNullable<CreateAgentOptions['meta']> }
/**
* Creation inputs; absent for a cold resume, which loads the persisted
* session — including the delegation policy events a fresh creation seeded,
* so a resume never re-captures the parent's policy.
*/
create?: {
seed: readonly SessionEvent[]
meta: NonNullable<CreateAgentOptions['meta']>
/** Policy captured at the delegation boundary: the parent's sandbox override plus the approval pin. */
delegatedPolicies: DelegatedPolicyOverrides
}
agentOptions: AgentOptions
composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined }
signal: AbortSignal
@@ -341,6 +353,9 @@ export class SubagentContinuationManager {
...request.persona !== undefined ? { persona: request.persona } : {},
...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
})
// Capture before the first await: a later parent switch belongs to the
// parent's future, not to this child.
const delegatedPolicies = captureDelegatedPolicyOverrides(parent)
const prepared = await this.host.prepareContinuable(spec.provider, {
sessionId: childId,
@@ -357,7 +372,7 @@ export class SubagentContinuationManager {
childId,
provider: spec.provider,
parent,
create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) },
create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies },
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
composition: { persona: request.persona, toolFilter: request.toolFilter },
signal: spec.signal,
@@ -878,18 +893,23 @@ export class SubagentContinuationManager {
inputs: MaterializeInputs,
parentLineage: readonly Agent[],
): Promise<Activation> {
const { childId, provider, parent } = inputs
const { childId, provider, parent, create } = inputs
// No id pre-check here: the child lock serializes each durable child, both
// callers reach this only after confirming no Activation exists, and
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
// some other owner holds — a duplicate would reject there with rollback.
inputs.signal.throwIfAborted()
const setup = (childCtx: Context): AgentSetupCommit => {
// Only fresh creation seeds the delegation policy onto the child's own
// log (after any fork seed, so fresh policy wins stale seed state); a
// cold resume replays those persisted events instead.
if (create !== undefined) {
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.delegatedPolicies)
}
applyChildComposition(childCtx, parent, inputs.composition)
return this.setupRegistry.apply(childCtx)
}
const observer = this.host.observeActivation(provider, childId, parent)
const { create } = inputs
// Agent creation owns rollback before handle transfer. A rejection leaves
// no resident Activation and therefore publishes no lifecycle edge.
const handle: AgentHandle = create === undefined

View File

@@ -100,13 +100,15 @@ export { SubagentError } from './error.ts'
export { settleRun } from './run-settlement.ts'
export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts'
export {
appendDelegatedPolicyOverrides,
applyChildComposition,
captureDelegatedPolicyOverrides,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
SubagentDepthError,
} from './child-agent.ts'
export type { ChildComposition } from './child-agent.ts'
export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts'
export type {
ContinuableStart,
ContinuableStartSpec,

View File

@@ -0,0 +1,231 @@
/**
* Continuable-child delegation policy: a fresh continuable start seeds the
* parent's explicit sandbox override and the pinned `approval/policy: never`
* onto the child's own log as `source: 'delegation'` events, and a cold
* resume replays that persisted snapshot instead of re-capturing the parent
* (the one-shot `subagent-inprocess/tests/inheritance.spec.ts` counterpart).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the continuable stack plus both policy services the manager consumes opportunistically. */
async function setup(script: Script) {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root })
await ctx.plugin(ApprovalService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(SubagentFork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
function startSpec(parent: Agent, provider = 'spawn') {
return {
provider,
label: 'child task',
request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
signal: new AbortController().signal,
}
}
/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
await vi.waitFor(() => {
expect(ctx.agents.get(childId)).toBeUndefined()
}, { timeout: 15_000 })
}
function policyEvents(events: readonly SessionEvent[]) {
return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy')
}
describe('continuable policy inheritance', () => {
it('seeds the parent sandbox override and pins approval to never', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('child done')])
setSandboxMode(parent.session, 'danger-full-access')
// No parent approval override: the child pin must not depend on one.
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
let child: Agent | undefined
ctx.on('agent/created', ({ agent }) => {
if (agent !== parent) child = agent
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
// The delegation events are appended in the creation window, so they are
// already the child's effective policy at inbox acceptance.
if (child === undefined) throw new Error('expected the continuable child to be created')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
expect(ctx.approval.overrideOf(child.session)).toBe('never')
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(policyEvents(loaded.events)).toMatchObject([
{ type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } },
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
// Durable: a reload folds the same effective policy.
expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access')
expect(effectiveApprovalPolicy(loaded.events)).toBe('never')
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
const runtimeContext = loaded.events.find(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt',
)
const contextText = runtimeContext?.data.content
.flatMap(block => block.type === 'text' ? [block.text] : [])
.join('\n')
expect(contextText).toContain('You are a delegated subagent')
})
it('captures policy at delegation before asynchronous child creation', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('child done')])
setSandboxMode(parent.session, 'read-only')
const starting = ctx.subagents.startContinuable(startSpec(parent))
// A parent switch after the synchronous capture belongs to the parent's
// future, not to this child.
setSandboxMode(parent.session, 'danger-full-access')
const started = await starting
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
})
it('leaves an unswitched sandbox on the deployment default while still pinning approval', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('child done')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(policyEvents(loaded.events)).toMatchObject([
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBeUndefined()
})
it('pins approval after the fork prefix of an unswitched fork child', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')])
parent.followup(createUserMessage({
content: [{ type: 'text', text: 'parent work' }],
source: { kind: 'user' },
}))
await parent.whenIdle()
const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork'))
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.meta.seedLength).toBeGreaterThan(0)
expect(policyEvents(loaded.events)).toMatchObject([
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBeUndefined()
})
it('lets a later child-side switch win over the delegation snapshot', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('child done')])
setSandboxMode(parent.session, 'danger-full-access')
let child: Agent | undefined
ctx.on('agent/created', ({ agent }) => {
if (agent !== parent) child = agent
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
if (child === undefined) throw new Error('expected the continuable child to be created')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
// Last event wins: the child's own runtime switch beats the seeded snapshot.
setSandboxMode(child.session, 'read-only')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
})
it('cold-resumes on the persisted snapshot without re-capturing the parent', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')])
setSandboxMode(parent.session, 'read-only')
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
// The parent widens AFTER the child was created; the resumed child keeps
// the delegation-time snapshot from its own log.
setSandboxMode(parent.session, 'danger-full-access')
await ctx.subagents.followup(parent, started.childId, [{ type: 'text', text: 'continue please' }], {
source: { kind: 'user' },
signal: new AbortController().signal,
})
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ data: { mode: 'read-only', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
// The approval pin is seeded once at creation, never re-appended on resume.
expect(loaded.events.filter(event => event.type === 'approval/policy')).toMatchObject([
{ data: { policy: 'never', source: 'delegation' } },
])
})
it('places inherited events after a fork prefix so fresh policy wins stale seed state', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')])
// The stale mode lands inside the completed turn the fork seed replays.
setSandboxMode(parent.session, 'workspace-write')
parent.followup(createUserMessage({
content: [{ type: 'text', text: 'parent work' }],
source: { kind: 'user' },
}))
await parent.whenIdle()
setSandboxMode(parent.session, 'read-only')
const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork'))
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.meta.seedLength).toBeGreaterThan(0)
expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ data: { mode: 'workspace-write' } },
{ data: { mode: 'read-only', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
})
})

View File

@@ -103,9 +103,9 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean {
&& event.data.content.some(block => block.type === 'text' && block.text === text))
}
/** Every user-role message text in log order, for FIFO assertions. */
/** Caller-supplied user message texts in log order (runtime-context snapshots excluded). */
function userTexts(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'user/message'
return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}

View File

@@ -26,9 +26,18 @@
{
"path": "../../core/scope"
},
{
"path": "../../interaction/user-approval"
},
{
"path": "../../preset/agent-presets"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../session/session-persistence"
},

View File

@@ -158,7 +158,7 @@ describe('dsh-tool-subagent-control', () => {
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
const prompts = loaded.events.flatMap(event => event.type === 'user/message'
const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
// A follow-up is its own later turn, never steering inside the first one.
@@ -274,7 +274,7 @@ describe('dsh-tool-subagent-control interrupt_agent', () => {
expect(waking.isError).toBe(false)
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
const prompts = loaded.events.flatMap(event => event.type === 'user/message'
const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up'])

View File

@@ -411,9 +411,9 @@ describe('dsh-tool-subagent-report', () => {
})
})
/** Prove report delivery uses ordinary logged user messages. */
/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */
function userTexts(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'user/message'
return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}