From bf4356cf354bee27e67fb46b4b3d1af91f607fc2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 10:26:43 +0800 Subject: [PATCH] feat(web): let a blank session switch its agent preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentPreset.select` recomposes one session's agent from a different preset. It is allowed only while the session is blank — once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive; only the preset subtree is swapped. That was forced by what the host actually owns: api-proxy discards the `AgentHandle` it creates, and there is no delete RPC, so neither disposing nor recreating the session was available. Swapping the subtree is also the better answer — the session id, its workspace attachment, and its projections all stay put. `recompose` is unmount-then-mount because two compositions cannot coexist: both would register the same tool names into one layer. So it resolves the new preset BEFORE tearing anything down (an unknown id is a no-op) and restores the previous composition when the new one fails to mount, rather than leaving the agent with no tools at all. Both paths are pinned by test. Also restores the English half of the `agentPreset.list` README paragraph, which was lost before the previous commit — and `verify-translation-pairing --write` recorded the pair as consistent anyway, because it records whatever state it finds rather than checking the two sides say the same thing. --- docs/cordis-catalog/services.md | 21 ++++++- .../client/connection/src/client/fixture.ts | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 4 ++ packages/host/apiproxy/README.i18n.yaml | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 49 ++++++++++++++++ .../apiproxy/src/api/agent-presets.schema.ts | 12 ++++ .../host/apiproxy/src/api/agent-presets.ts | 12 ++++ packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + packages/host/apiproxy/src/fetch/client.ts | 6 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../tests/api-proxy-agent-preset.spec.ts | 57 +++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 6 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 4 ++ packages/preset/agent-presets/src/index.ts | 47 ++++++++++++++- packages/preset/agent-presets/src/mount.ts | 27 ++++++++- .../preset/agent-presets/tests/mount.spec.ts | 49 ++++++++++++++++ 19 files changed, 295 insertions(+), 11 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 190f054be3..1dad9b9a01 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -96,9 +96,28 @@ async mount(agentCtx: Context, id?: string): Promise * @returns the agent's instance, or undefined when its preset mounts none. */ serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined + +/** + * Replace the composition installed for one agent. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot make. + * The CALLER owns that check — this method does not read session history. + * + * The swap is unmount-then-mount because two compositions cannot coexist: + * both would register the same tool names into one layer. A failed mount + * therefore restores the previous composition rather than leaving the agent + * with nothing. + * @param agentCtx - the agent's scope context. + * @param id - the profile to compose the agent from instead. + * @returns the profile now installed. + * @throws when the profile is unknown or its composition is unusable; the + * previous composition is restored first. + */ +async recompose(agentCtx: Context, id: string): Promise ``` -Source: [`packages/preset/agent-presets/src/index.ts:54`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:56`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 814796f0fa..db28dff49d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2321,6 +2321,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { { id: 'core-web', trust: 'system' as const, isDefault: false }, ], }), + select: request => ok(request, { agentPreset: request.payload.agentPreset }), }, skills: { @@ -2621,6 +2622,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.list': return this.api.agentPresets.list(request) + case 'agentPreset.select': return this.api.agentPresets.select(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2ee97f3d2e..fec265d09c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -100,6 +100,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined', jsDoc: '/**\n * One agent\'s instance of a service its preset mounted.\n *\n * A preset publishes services behind `isolate` realms, which are invisible\n * outside the group that declares them — including to the host. This is how a\n * caller holding the agent reads one anyway: a request that is ABOUT a\n * session but arrives from outside it, which is every browser RPC.\n *\n * Read addressing only. A host row that `inject`s a service cannot use this,\n * because injection resolves before any session exists and has no agent to\n * key by; such a service belongs on the host plane instead.\n * @param agent - the agent whose composition to look inside.\n * @param name - the service name as the preset\'s rows resolve it.\n * @returns the agent\'s instance, or undefined when its preset mounts none.\n */', }, + { + signature: 'async recompose(agentCtx: Context, id: string): Promise', + jsDoc: '/**\n * Replace the composition installed for one agent.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot make.\n * The CALLER owns that check — this method does not read session history.\n *\n * The swap is unmount-then-mount because two compositions cannot coexist:\n * both would register the same tool names into one layer. A failed mount\n * therefore restores the previous composition rather than leaving the agent\n * with nothing.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile to compose the agent from instead.\n * @returns the profile now installed.\n * @throws when the profile is unknown or its composition is unusable; the\n * previous composition is restored first.\n */', + }, ], }, { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 990a055b43..673d5b8d0a 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md README.md: 9484fadcc798652979f998c81f84444c1ebdbf52 -README.zh.md: 44e1d4b563e52c2491e07854469bbb283e30b28b +README.zh.md: 238339213f6fec0f3f466907e5994953f391cd26 diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 21574c84e7..87f1a702dd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -34,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。该领域只读——preset 是磁盘上的一份组装,创作它是文件系统行为而非 RPC。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。 +`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f47b9c8f16..24ca249f5b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2500,6 +2500,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })), }) }, + + // Recomposing is limited to a blank session because a started + // conversation's history was produced under its preset's tools; the + // agent and the session survive, only the composition is swapped. + async select(request) { + const { sessionId, agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: 'this deployment composes no agent presets', + details: { agentPreset, available: [] }, + }) + } + 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 }, + }) + } + try { + const preset = await presets.recompose(agent.ctx, agentPreset) + 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: {}, + }) + } + }, }, skills: { diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts index 0d4881d8cd..ed0312506c 100644 --- a/packages/host/apiproxy/src/api/agent-presets.schema.ts +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' +import { sessionIdSchema } from './sessions.schema.ts' import type { AgentPresetEntry } from './agent-presets.ts' /** AgentPresetEntry row of agentPreset.list. */ @@ -23,3 +24,14 @@ export const agentPresetListRequestSchema = z.object({ export const agentPresetListValueSchema = z.object({ presets: z.array(agentPresetEntrySchema), }) satisfies z.ZodType>> + +/** agentPreset.select request payload. */ +export const agentPresetSelectRequestSchema = z.object({ + sessionId: sessionIdSchema, + agentPreset: z.string().min(1), +}) satisfies z.ZodType>> + +/** agentPreset.select response value. */ +export const agentPresetSelectValueSchema = z.object({ + agentPreset: z.string(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts index 0f537f6a0f..83630d1dc3 100644 --- a/packages/host/apiproxy/src/api/agent-presets.ts +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -4,6 +4,7 @@ * a filesystem act rather than an RPC. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcRequest, RpcResponse } from './rpc.ts' /** One preset the deployment can compose a session's agent from. */ @@ -28,4 +29,15 @@ export interface AgentPresetsApi { * every session shares the host composition. */ list(request: RpcRequest<{}>): Promise> + + /** + * Recompose one session's agent from a different preset. + * + * Allowed only while the session is blank — no turn has run. Once a + * conversation starts, its history was produced under that preset's tools, + * and swapping them would leave logged tool calls the new composition cannot + * make; the attempt answers `agent-preset-locked`. + */ + select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>): + Promise> } diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 726f7118d4..d0a6c7c292 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -52,6 +52,7 @@ export interface RpcMethodMap { 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] 'agentPreset.list': AgentPresetsApi['list'] + 'agentPreset.select': AgentPresetsApi['select'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 6da119d5c3..12e19e61f5 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -46,6 +46,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), + z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 9bfb30bd11..5c412e4d15 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,7 @@ export interface RpcErrorDetailsMap { 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } + 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } 'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string } 'agent-preset-not-found': { agentPreset: string; available: string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index c5eda9ca41..92504f0872 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -40,7 +40,7 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' -import { agentPresetListValueSchema } from '../api/agent-presets.schema.ts' +import { agentPresetListValueSchema, agentPresetSelectValueSchema } from '../api/agent-presets.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -122,6 +122,7 @@ export interface IApiClient { } readonly agentPresets: { list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>> + select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -190,6 +191,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType, signal?: AbortSignal) => this.callUnary('agentPreset.list', payload, signal), + select: (payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal) => + this.callUnary('agentPreset.select', payload, signal), } readonly goals: IApiClient['goals'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 278fa57c0b..9063c2e831 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -42,7 +42,7 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' -import { agentPresetListRequestSchema } from '../api/agent-presets.schema.ts' +import { agentPresetListRequestSchema, agentPresetSelectRequestSchema } from '../api/agent-presets.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -111,6 +111,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, + 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index abcedeb8d3..5473d3ec63 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -52,6 +52,10 @@ function roster(ids: readonly string[]): unknown { const perAgent = services.get(String(agent.id)) return perAgent?.[name] }, + recompose: (_ctx: Context, id: string) => { + if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) + return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` }) + }, } } @@ -262,3 +266,56 @@ describe('agentPreset.list', () => { expect(response.result.value.presets).toEqual([]) }) }) + +describe('agentPreset.select', () => { + it('recomposes a blank session', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-1'), agentPreset: 'core-web' })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.agentPreset).toBe('core-web') + }) + + it('refuses once the conversation has started', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' })) + // One turn is enough: the history from here on was produced under + // `standard`'s tools, and a swap would strand those tool calls. + ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 }) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-2'), agentPreset: 'core-web' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-locked') + }) + + it('reports an unknown preset without disturbing the session', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('sel-3') })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('reports a deployment that composes no presets', async () => { + const { api } = await harness() + await api.sessions.create(request({ sessionId: SessionId('sel-4') })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ed4a5341f1..bc3f6aa840 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -87,7 +87,11 @@ function scriptedApi(overrides: { ...overrides.commands, }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, - agentPresets: { list: r => ok(r, { presets: [] }), ...overrides.agentPresets }, + agentPresets: { + list: r => ok(r, { presets: [] }), + select: r => ok(r, { agentPreset: r.payload.agentPreset }), + ...overrides.agentPresets, + }, goals: { create: err, edit: err, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 4dc46ce66a..c792def3b4 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -197,6 +197,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra list(request: RpcRequest<{}>) { return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { presets: [] } } }) }, + select(request: RpcRequest<{ agentPreset: string }>) { + const value = { agentPreset: request.payload.agentPreset } + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } }) + }, }, skills: { async list(request) { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 6914445f08..e6a74c346c 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -11,10 +11,11 @@ */ import { Context, Service } from 'cordis' +import { scopeOf } from '@deepseek-ai/dsh-scope' import z from 'schemastery' import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' -import { mountPreset, serviceForAgent } from './mount.ts' +import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts' import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ @@ -33,7 +34,8 @@ export const AgentPresetSettingsSchema: z = z.object({ export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' export { - inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount, + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, + unmountPresetFor, type PresetMount, } from './mount.ts' export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' @@ -157,6 +159,47 @@ export class AgentPresets extends Service { serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined { return serviceForAgent(this.ctx, agent, name) } + + /** + * Replace the composition installed for one agent. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot make. + * The CALLER owns that check — this method does not read session history. + * + * The swap is unmount-then-mount because two compositions cannot coexist: + * both would register the same tool names into one layer. A failed mount + * therefore restores the previous composition rather than leaving the agent + * with nothing. + * @param agentCtx - the agent's scope context. + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable; the + * previous composition is restored first. + */ + async recompose(agentCtx: Context, id: string): Promise { + const scope = scopeOf(agentCtx) + if (scope === undefined) { + throw new Error('agent-presets: refusing to recompose an unscoped context') + } + // Resolve before tearing anything down, so an unknown id leaves the agent + // exactly as it was. + const preset = await this.resolve(id) + const previous = await unmountPresetFor(scope) + try { + await mountPreset(agentCtx, preset) + } catch (error) { + if (previous !== undefined && previous !== preset.id) { + await this.mount(agentCtx, previous).catch(() => { + // The agent now has no composition, but the switch failure below is + // the actionable diagnostic and the restore had the same inputs that + // worked a moment ago; reporting its failure instead would hide why. + }) + } + throw error + } + return preset + } } export default AgentPresets diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 6834a24c66..261aea0b3a 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -18,7 +18,7 @@ import { pathToFileURL } from 'node:url' import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' import type { EntryTree } from '@cordisjs/plugin-loader' -import { scopeOf } from '@deepseek-ai/dsh-scope' +import { scopeOf, type ScopeKey } from '@deepseek-ai/dsh-scope' import { PresetMountError, type AgentPreset } from './types.ts' /** What one mounted subtree publishes about itself for the audit to read. */ @@ -77,6 +77,8 @@ export interface PresetMount { readonly presetId: string /** The mounted subtree's fiber. */ readonly fiber: Fiber + /** The scope the subtree was mounted for — the agent that owns it. */ + readonly scope: ScopeKey } const mounts = new Set() @@ -113,6 +115,24 @@ export function livePresetMounts(): PresetMount[] { return [...mounts] } +/** + * Discard the composition currently installed for one scope, if any. + * + * Only a composition that has produced nothing may be replaced: swapping a + * live agent's tools mid-conversation would leave logged tool calls the new + * composition cannot make. The caller owns that check — this function does the + * teardown and returns once the subtree is quiescent. + * @param scope - the agent whose installed composition to discard. + * @returns the preset id that was discarded, or `undefined` when none was. + */ +export async function unmountPresetFor(scope: ScopeKey): Promise { + const installed = livePresetMounts().find(mount => mount.scope === scope) + if (installed === undefined) return undefined + mounts.delete(installed) + await Promise.resolve(installed.fiber.dispose()) + return installed.presetId +} + /** * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree. * @@ -241,7 +261,8 @@ export function inactiveRows(tree: EntryTree): string[] { * published a service into the root realm. */ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise { - if (scopeOf(agentCtx) === undefined) { + const scope = scopeOf(agentCtx) + if (scope === undefined) { throw new Error( `agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; ` + 'its registrations would apply to every agent in the process', @@ -269,7 +290,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi + 'a preset service must sit behind an `isolate` realm or move to the host composition', ) } - mounts.add({ presetId: preset.id, fiber }) + mounts.add({ presetId: preset.id, fiber, scope }) } catch (error) { try { await handle.dispose() diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index e322ceb7ad..495a15b6d7 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -287,3 +287,52 @@ describe('the preset file is an input, never a persistence target', () => { expect(await readFile(path, 'utf8')).toBe(composition) }) }) + +describe('replacing a composition', () => { + it('swaps the agent\'s tools without touching another session', async () => { + const keeper = await agentOn(ctx, 'sess-keeper', 'standard') + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-swap'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + expect(toolNames(ctx, keeper)).toEqual(['alpha']) + expect(toolNames(ctx)).toEqual([]) + }) + + it('leaves the agent on its previous composition when the new one is unknown', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-unknown'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'nope')) + .rejects.toThrow(/not found/) + + // Resolution happens before any teardown, so an unknown id is a no-op. + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('restores the previous composition when the new one fails to mount', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-restore'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) + .rejects.toThrow(/failed to mount/) + + // The swap is unmount-then-mount, so a failure must put the old one back + // rather than leave the agent with no tools at all. + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('refuses an unscoped context', async () => { + await expect(ctx.agentPresets.recompose(ctx, 'minimal')) + .rejects.toThrow(/unscoped context/) + }) +})