feat(web): let a blank session switch its agent preset

`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.
This commit is contained in:
Yichen Jiang
2026-08-04 10:26:43 +08:00
parent 6758da87ae
commit bf4356cf35
19 changed files with 295 additions and 11 deletions

View File

@@ -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<AgentPresetSettings> = 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<K extends string & keyof Context>(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<AgentPreset> {
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

View File

@@ -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<PresetMount>()
@@ -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<string | undefined> {
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<void> {
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()

View File

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