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
@@ -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'))