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/) + }) +})