fix(apiproxy): echo the preset a created session runs, not its header

`session.create` also adopts an already-live session, and the preceding
commit newly allows adopting one under the preset it switched to while
blank. Its response still echoed `header.agentPreset`, so that adoption
answered with the preset the session had just left — contradicting the
request it had accepted and the row `session.list` serves for the same
session from `resolveSessionPreset()`. The echo now resolves the same way.

The `assertPresetUnchanged` parameter doc said `existing` was the preset
the session was created under; both callers now pass what it runs.

`composeFrom()` was documented as "infallible" and "cannot fail" beside two
`@throws`. It has no composition failure mode — no roster read, no mount, no
file — but it does reject a caller error, and the wording now says which.

The package-level "switched preset" test re-linked to the same preset id,
so it could not tell reading the parent's live scope chain from reading its
creation header. A second fixture preset makes the switch real.

The Web browser lane's subagent goldens gain the preset badge a child now
shows, which is the visible consequence of recording its composition. That
lane runs only under DSH_EXAMPLE_MODE=lib and was missed before.

The Agent Note records two limits found in review: a cold-resumed
continuable child joins its parent's current composition rather than the one
its header names, and `toolFilter` does not constrain a joined child. The
latter is a regression from the agent-plane move rather than anything this
change introduces — with the same tools in the global layer the filter
applies normally — and is tracked in #2185.

Refs #2185
This commit is contained in:
Yichen Jiang
2026-08-10 19:28:40 +08:00
parent da5d9aaee9
commit 2f481fa352
17 changed files with 70 additions and 27 deletions

View File

@@ -1038,7 +1038,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
* common paths — reconnecting, resuming, retrying a create — are unaffected.
* @param sessionId - the identity being adopted.
* @param requested - the preset the request named, if any.
* @param existing - the preset the session was created under, if any.
* @param existing - the preset the session RUNS, if any; both callers resolve
* it from the log, which differs from the creation header once a blank
* session has switched.
* @throws when both are present and differ.
*/
function assertPresetUnchanged(
@@ -1989,12 +1991,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
}
// Echo the RESOLVED composition so a client can label the session it
// just created without waiting for the next list refresh — the create
// is the commit point that knows it (a caller that named none gets
// the default the header recorded).
// Echo the composition the session RUNS so a client can label it
// without waiting for the next list refresh — the create is the commit
// point that knows it (a caller that named none gets the default).
// Resolved from the log for the same reason `sessionListFields()` is:
// this handler also adopts an already-live session, and one that
// switched while blank runs a preset its header no longer names, so
// echoing the header would contradict both the adoption this call just
// allowed and the row `session.list` serves for the same session.
const created = ctx.agents.get(sessionId)
const createdPreset = created?.session.header.agentPreset
const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
},

View File

@@ -199,6 +199,11 @@ describe('session.create with an agent preset', () => {
// Comparing against the header would invert both answers: the preset the
// session actually runs would be refused, and the one it left would pass.
expect(adopted.result.ok).toBe(true)
// The echo has to name the same preset the adoption just accepted, or the
// client labels the session with one it has already left — and disagrees
// with the row `session.list` serves for it.
if (!adopted.result.ok) throw new Error('unreachable')
expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' })
expect(stale.result.ok).toBe(false)
if (stale.result.ok) throw new Error('unreachable')
expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' })

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: 5ccf1d7b224d0e3a67b3aeb9dc6679d6e802063f
README.zh.md: ed79cf48b96ed927feec8860b6211cedc369cdda
README.md: 250d2a6560e680aee5d3088834d5db220854d1d4
README.zh.md: bd02327a3cca01bc794d63c8933b61bfa5c1008b

View File

@@ -14,7 +14,7 @@ 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.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined).
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built.
- `ctx.agentPresets.recompose(agentCtx, id): Promise<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()`.

View File

@@ -14,7 +14,7 @@
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 presetid 重复时靠前的根目录胜出;损坏的 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.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步、且自身没有组装失败模式;调用方用错(上下文无 scope、agent 已加入过)仍会拒绝
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent这是唯一能拿到的答案。
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。

View File

@@ -250,9 +250,11 @@ export class AgentPresets extends Service {
* 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
* Synchronous, and with no composition failure mode of its own — it reads no
* roster, mounts nothing, and touches no file — which is what lets a child
* creation window use it: the two in-process subagent drivers compose their
* children inside a synchronous `setup`.
* children inside a synchronous `setup`. It still rejects a caller error, as
* the `@throws` below record.
*
* 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

View File

@@ -112,7 +112,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined',
jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous and infallible for that reason, which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */',
jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous, and with no composition failure mode of its own — it reads no\n * roster, mounts nothing, and touches no file — which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`. It still rejects a caller error, as\n * the `@throws` below record.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */',
},
{
signature: 'composedPreset(agentCtx: Context): string | undefined',

View File

@@ -0,0 +1,6 @@
# A second agent-plane composition, so a switch is a real switch: the tool a
# joined child sees has to change with it.
- id: only
name: ../../plugins/preset-tool.js
config:
tool: reviewing_only

View File

@@ -105,12 +105,16 @@ describe('a child agent composed in-process', () => {
it('follows a parent that switched preset while blank', async () => {
const { ctx, parent } = await setupPresetHost()
await ctx.agentPresets.recompose(parent.ctx, 'coding')
// A DIFFERENT preset, so the assertion below distinguishes reading the
// parent's live scope chain from reading its creation header — re-linking
// to the same id would pass either way.
await ctx.agentPresets.recompose(parent.ctx, 'reviewing')
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only'])
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only'])
expect(run.localAgent?.session.header.agentPreset).toBe('reviewing')
await run.dispose()
})
})