Merge branch 'stack/agent-profiles-7-docs' into stack/agent-profiles-8-authoring

# Conflicts:
#	packages/client/ui-agent-preset/src/client/settings-store.ts
#	packages/client/ui-agent-preset/tests/settings-store.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
This commit is contained in:
Yichen Jiang
2026-08-07 11:49:30 +08:00
7 changed files with 162 additions and 38 deletions

View File

@@ -779,6 +779,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const targets = new WeakMap<Agent, WebLlmTargetRef>()
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
/**
* Serializes `agentPreset.select` per session. Two concurrent selects both
* pass the blank check, and the second `unmountPresetFor` then finds nothing
* to unmount because the first already removed the record — leaving two
* compositions registered into one agent layer. The client's `busy` flag is
* not enforcement: the wire is reachable directly.
*/
const presetSwitches = new Map<SessionId, Promise<unknown>>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
const sessionCreations = new Map<SessionId, Promise<Agent>>()
/** Serializes path ownership and explicit title checks with Workspace mutations. */
@@ -1153,7 +1161,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
setup: (await composeAgent(inspected.meta.agentPreset)).setup,
// Resolved from the LOG, not the header: a session that switched
// while blank ran its turns under the newer composition, and the
// header is written once at creation. Reading the header here
// would silently undo the switch on the next restart and restore
// that history under the old tool set.
setup: (await composeAgent(
resolveSessionPreset({ header: inspected.meta, events: inspected.events }),
)).setup,
})
return handle.agent
} finally {
@@ -2565,39 +2580,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const { agent } = found
if (!sessionBlank(agent.session)) {
return err(request, {
code: 'agent-preset-locked',
message: `session "${sessionId}" has already started; its agent preset is fixed`,
details: { sessionId, agentPreset },
})
const swap = async (): Promise<RpcResponse<{ agentPreset: string }>> => {
// Re-read inside the queue: an earlier switch may have run, and a
// conversation may have started, since this request arrived.
if (!sessionBlank(agent.session)) {
return err(request, {
code: 'agent-preset-locked',
message: `session "${sessionId}" has already started; its agent preset is fixed`,
details: { sessionId, agentPreset },
})
}
try {
const preset = await presets.recompose(agent.ctx, agentPreset)
// Recorded only after the swap committed: the log states what the
// agent runs, and a rejected mount leaves the previous composition.
agent.session.append('agent-preset/selected', { agentPreset: preset.id })
return ok(request, { agentPreset: preset.id })
} catch (error: unknown) {
if (error instanceof UnknownPresetError) {
return err(request, {
code: 'agent-preset-not-found',
message: error.message,
details: { agentPreset: error.presetId, available: [...error.available] },
})
}
if (error instanceof PresetMountError) {
return err(request, {
code: 'agent-preset-invalid',
message: error.message,
details: { agentPreset: error.presetId, reason: error.reason },
})
}
return err(request, {
code: 'internal',
message: `failed to select agent preset "${agentPreset}": ${String(error)}`,
details: {},
})
}
}
const queued = presetSwitches.get(sessionId) ?? Promise.resolve()
const turn = queued.then(swap)
presetSwitches.set(sessionId, turn.catch(() => undefined))
try {
const preset = await presets.recompose(agent.ctx, agentPreset)
// Recorded only after the swap committed: the log states what the
// agent runs, and a rejected mount leaves the previous composition.
agent.session.append('agent-preset/selected', { agentPreset: preset.id })
return ok(request, { agentPreset: preset.id })
} catch (error: unknown) {
if (error instanceof UnknownPresetError) {
return err(request, {
code: 'agent-preset-not-found',
message: error.message,
details: { agentPreset: error.presetId, available: [...error.available] },
})
}
if (error instanceof PresetMountError) {
return err(request, {
code: 'agent-preset-invalid',
message: error.message,
details: { agentPreset: error.presetId, reason: error.reason },
})
}
return err(request, {
code: 'internal',
message: `failed to select agent preset "${agentPreset}": ${String(error)}`,
details: {},
})
return await turn
} finally {
if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId)
}
},