fix(subagent): compose children from their parent's preset

Tool and prompt-section visibility is inherited along dsh-scope's parent
chain, and an agent's scope key is minted with no parent. Per-session agent
presets moved every model-facing row onto the agent plane and made
AgentPresets.mount() the one thing that binds that link, from the api-proxy's
session create, resume, and fork paths. The two in-process subagent drivers
installed only the per-child persona and tool filter, so a child's scope chain
had length one and its registry view resolved the global layer alone — which
is empty wherever a preset roster is composed. One-shot children reached the
model with no tools, continuable ones with only the host-plane `report`, and
neither carried its parent's persona, workspace context, or skill catalog.

AgentPresets.composeFrom() joins one agent to the standing composition another
already runs on. It is a bind, not a mount: the child gets its parent's exact
generation, so a composition edited since the parent started cannot fork it
onto another one, and it is synchronous, which is what lets a child creation
window use it. applyChildComposition() now takes the parent and performs the
join first, making a child composed without it unrepresentable at the call
sites. childSessionMeta() records the joined id so a cold read rebuilds the
composition the child actually ran under.

The audit that followed found two api-proxy readers on the wrong authority:
presenterScopeFor() and the live-agent branch of assertPresetUnchanged() both
read header.agentPreset, which goes stale the moment a blank session switches
preset. A switched session's cold transcript resolved presenters in the older
composition's layer and silently degraded to generic cards, and the gateway
refused to adopt a live session under the preset it actually runs while
accepting the one it left. Both now resolve through resolveSessionPreset(),
matching the resume branch fifteen lines above. The owning architecture Agent
Note carried the stale claim that the header records what a session runs; it
is corrected to name the header/log pair and its three readers.

Fixes #2165
This commit is contained in:
Yichen Jiang
2026-08-10 17:46:34 +08:00
parent 3c7e7262c1
commit e53f448650
36 changed files with 698 additions and 41 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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: b6d469b26a0254adc654e5cc49d3df2d10817b2d
README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21
README.md: 5ccf1d7b224d0e3a67b3aeb9dc6679d6e802063f
README.zh.md: ed79cf48b96ed927feec8860b6211cedc369cdda
+10
View File
@@ -14,6 +14,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason.
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row.
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved.
- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and cannot fail.
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built.
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
@@ -27,6 +29,14 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions.
### Composing a child agent
A subagent's child joins its parent's standing composition through `composeFrom()`, never through `mount()`. Every model-facing row lives on the agent plane, so the tool registry's global layer is empty and a child that joins nothing reaches the model with no tools at all and none of its parent's prompt sections.
Re-mounting the parent's preset by id would differ from the bind in two ways that both matter. A composition file edited since the parent started would hand the child a DIFFERENT generation than the one its parent's history was produced under, and a preset deleted since would fail the child outright while its parent keeps running. The bind is also synchronous, which is what lets the in-process subagent drivers use it at all — they compose their children inside a synchronous creation window.
The child records the joined id on its own durable header ([`dsh-subagent`](../../subagent/subagent/README.md)), so a cold read of the child's history rebuilds the composition it actually ran under rather than the deployment default.
### Which preset a session runs
The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header.
@@ -14,6 +14,8 @@
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。
- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步且不会失败。
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
@@ -27,6 +29,14 @@
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。
### 组装子 agent
subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,绝不走 `mount()`。所有面向模型的行都在 agent 平面,工具注册表的全局层是空的,因此没有加入任何组装的子 agent 抵达模型时既没有任何工具,也没有父方的任何提示段。
按 id 重新挂载父方的 preset 与认父有两处差别,且两处都要紧。父方启动后被编辑过的组装文件会把与父方历史所产出时**不同**的一个代际交给子 agent;而此后被删除的 preset 会让子 agent 直接失败,尽管其父方仍在正常运行。认父还是同步的,这正是进程内 subagent 驱动能够使用它的前提——它们在同步的创建窗口里组装子 agent。
子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。
### 会话实际运行的是哪个 preset
创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。
+51 -3
View File
@@ -28,7 +28,7 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
import { mountPreset, serviceForAgent } from './mount.ts'
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
import { PresetExistsError } from './authoring.ts'
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts'
@@ -51,8 +51,8 @@ export {
METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata,
} from './metadata.ts'
export {
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
type PresetMount,
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor,
type JoinedPresetMount, type PresetMount,
} from './mount.ts'
export {
copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError,
@@ -238,6 +238,54 @@ export class AgentPresets extends Service {
return preset
}
/**
* Join one agent to the SAME standing composition another already runs on.
*
* This is how a child agent inherits its parent's capabilities. It is a bind,
* not a mount: the parent's generation is already composed, so the child gets
* that exact instance — the same plugin objects, the same tool registrations,
* the same prompt sections. Re-resolving the parent's preset by id instead
* would re-read the roster, and a composition file edited since the parent
* started would hand the child a DIFFERENT generation than the one its
* parent's history was produced under (and a preset deleted since would fail
* the child outright while its parent keeps running).
*
* Synchronous and infallible for that reason, which is what lets a child
* creation window use it: the two in-process subagent drivers compose their
* children inside a synchronous `setup`.
*
* A parent that joined no preset — a rosterless deployment — yields no join
* and no error: there, the model-facing rows sit in the host composition and
* the child already sees them through the global layer.
* @param agentCtx - the joining agent's scope context.
* @param parentCtx - the scope context of the agent whose composition to join.
* @returns the preset id joined, or undefined when the parent joined none.
* @throws when `agentCtx` carries no scope, or has already joined a preset.
*/
composeFrom(agentCtx: Context, parentCtx: Context): string | undefined {
const agentKey = scopeOf(agentCtx)
if (agentKey === undefined) {
throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset')
}
const standing = standingMountFor(parentCtx)
if (standing === undefined) return undefined
this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key))
return standing.presetId
}
/**
* The preset one live agent runs on.
*
* Read from the live scope chain rather than from the session, so it answers
* for an agent whose session has not recorded a preset yet — a child agent
* whose durable header is being built from its parent's composition.
* @param agentCtx - the agent's scope context.
* @returns the preset id, or undefined when the agent joined none.
*/
composedPreset(agentCtx: Context): string | undefined {
return standingMountFor(agentCtx)?.presetId
}
/** Whether this deployment configures a root locally authored presets go to. */
get authorable(): boolean {
return this.config.roots.some(root => root.trust === 'user')
+28 -8
View File
@@ -202,6 +202,33 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] {
return leaked.sort((left, right) => left.localeCompare(right))
}
/** A live standing mount located through one agent already joined to it. */
export type JoinedPresetMount = PresetMount & {
/** The standing key, definite because it is what the lookup matched on. */
readonly key: ScopeKey
}
/**
* The standing composition one agent is joined to.
*
* The agent's own key is parented to its preset's standing key, so the mount
* is found by matching that parent rather than by walking up from the agent —
* the mount is not under the agent's fiber. An agent that joined no preset —
* a deployment composing no roster, or a child agent before its join — has no
* parent link and resolves to undefined.
* @param agentCtx - the agent's scope context.
* @returns the mount the agent joined, or undefined when it joined none.
*/
export function standingMountFor(agentCtx: Context): JoinedPresetMount | undefined {
const agentKey = scopeOf(agentCtx)
if (agentKey === undefined) return undefined
const standingKey = scopeParentOf(agentKey)
if (standingKey === undefined) return undefined
return livePresetMounts().find(
(candidate): candidate is JoinedPresetMount => candidate.key === standingKey,
)
}
/**
* One agent's instance of a service its preset mounted.
*
@@ -231,14 +258,7 @@ export function serviceForAgent<K extends string & keyof Context>(
agent: { ctx: Context },
name: K,
): Context[K] | undefined {
// The agent's own key is parented to its preset's standing key; the mount
// is no longer under the agent's fiber, so the search roots at the standing
// mount instead of walking up from the agent.
const agentKey = scopeOf(agent.ctx)
if (agentKey === undefined) return undefined
const standingKey = scopeParentOf(agentKey)
if (standingKey === undefined) return undefined
const mount = livePresetMounts().find(candidate => candidate.key === standingKey)
const mount = standingMountFor(agent.ctx)
if (mount === undefined) return undefined
const store = ctx.reflect.store
for (const key of Object.getOwnPropertySymbols(store)) {
@@ -153,6 +153,79 @@ describe('composing an agent from a preset', () => {
})
})
describe('composing a child agent from its parent', () => {
/** Create one agent joined to `parent`'s composition, as a child creation window does. */
async function childOf(ctx: Context, id: string, parent: Agent): Promise<Agent> {
const handle = await ctx.agents.create({
sessionId: SessionId(id),
setup: (childCtx: Context) => void ctx.agentPresets.composeFrom(childCtx, parent.ctx),
})
return handle.agent
}
it('gives the child its parent\'s tools and prompt sections', async () => {
const parent = await agentOn(ctx, 'sess-parent', 'standard')
const child = await childOf(ctx, 'sess-child', parent)
expect(toolNames(ctx, child)).toEqual(['alpha'])
const prompt = await ctx.systemPrompt.assemble(assembleContextFor(child))
expect(prompt.sections.map(section => section.name)).toContain('preset:alpha')
})
it('joins the parent\'s own generation rather than remounting its preset', async () => {
const parent = await agentOn(ctx, 'sess-shared', 'standard')
const before = livePresetMounts().length
await childOf(ctx, 'sess-shared-child', parent)
// A remount would compose a second copy of every row in the preset; the
// child must run on the plugin instances its parent already runs on.
expect(livePresetMounts()).toHaveLength(before)
})
it('keeps the child composed after its parent is disposed', async () => {
const parentHandle = await ctx.agents.create({
sessionId: SessionId('sess-dying-parent'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
const child = await childOf(ctx, 'sess-orphan', parentHandle.agent)
await parentHandle.dispose()
// Standing mounts outlive the agents that joined them, so a child outliving
// its parent — a background subagent — keeps the composition it started on.
expect(toolNames(ctx, child)).toEqual(['alpha'])
})
it('reports the preset id the child joined, for the durable header', async () => {
const parent = await agentOn(ctx, 'sess-named', 'minimal')
const child = await childOf(ctx, 'sess-named-child', parent)
expect(ctx.agentPresets.composedPreset(parent.ctx)).toBe('minimal')
expect(ctx.agentPresets.composedPreset(child.ctx)).toBe('minimal')
})
it('composes nothing when the parent joined no preset', async () => {
// The rosterless deployment: model-facing rows sit in the host composition
// and the child already resolves them through the registry's global layer.
const bare = (await ctx.agents.create({ sessionId: SessionId('sess-bare-parent') })).agent
const child = await childOf(ctx, 'sess-bare-child', bare)
expect(ctx.agentPresets.composedPreset(bare.ctx)).toBeUndefined()
expect(ctx.agentPresets.composeFrom(child.ctx, bare.ctx)).toBeUndefined()
expect(toolNames(ctx, child)).toEqual([])
})
it('refuses to compose an unscoped context', async () => {
const parent = await agentOn(ctx, 'sess-unscoped-parent', 'standard')
expect(() => ctx.agentPresets.composeFrom(ctx, parent.ctx)).toThrow(/unscoped context/)
})
})
describe('rejecting a composition that cannot be used', () => {
it('refuses to mount into a context that carries no agent scope', async () => {
await expect(ctx.agentPresets.mount(ctx, 'standard'))