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:
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ export interface AgentPresetEntry {
|
||||
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
||||
export interface AgentPresetsApi {
|
||||
/**
|
||||
* Lists every preset the deployment currently supplies, ordered by id.
|
||||
* Lists every preset the deployment currently supplies, in root-precedence
|
||||
* order — the roots as configured, each root's own presets sorted by id,
|
||||
* and the first root to supply an id wins. The order is not globally
|
||||
* sorted: a user root's preset sits in that root's block, not among the
|
||||
* shipped ids.
|
||||
* An empty roster means the deployment composes no presets at all, and
|
||||
* every session shares the host composition. `authorable` reports whether
|
||||
* the deployment configures a root new presets can be written to, which is
|
||||
|
||||
@@ -15,7 +15,7 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
||||
import {
|
||||
InvalidCompositionError, InvalidPresetIdError, UnknownPresetError,
|
||||
InvalidCompositionError, InvalidPresetIdError, resolveSessionPreset, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
@@ -299,6 +299,46 @@ describe('agentPreset.select', () => {
|
||||
expect(response.result.value.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('records the switch in the log, and the list reads it back', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' }))
|
||||
|
||||
await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' }))
|
||||
|
||||
// The header is written once at creation, so the switch lives in the log —
|
||||
// this is what a restart replays and what every projection resolves from.
|
||||
// Asserting only the RPC's echo would miss a switch that never persisted.
|
||||
const session = ctx.sessions.get(SessionId('sel-log'))
|
||||
if (session === undefined) throw new Error('unreachable')
|
||||
expect(session.header.agentPreset).toBe('standard')
|
||||
expect(resolveSessionPreset(session)).toBe('core-web')
|
||||
const listed = await api.sessions.list(request({}))
|
||||
if (!listed.result.ok) throw new Error('unreachable')
|
||||
expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset)
|
||||
.toBe('core-web')
|
||||
})
|
||||
|
||||
it('serializes two concurrent selects on one session', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))
|
||||
|
||||
// Both pass the blank check; unserialized, the second unmount finds no
|
||||
// record because the first already removed it, and two compositions end up
|
||||
// in one agent layer. The client's busy flag is not enforcement.
|
||||
const [first, second] = await Promise.all([
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })),
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
|
||||
])
|
||||
|
||||
expect(first.result.ok).toBe(true)
|
||||
expect(second.result.ok).toBe(true)
|
||||
const session = ctx.sessions.get(SessionId('sel-race'))
|
||||
if (session === undefined) throw new Error('unreachable')
|
||||
// One winner, and the log agrees with it: the last committed switch.
|
||||
expect(resolveSessionPreset(session)).toBe('standard')
|
||||
})
|
||||
|
||||
it('refuses once the conversation has started', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
|
||||
|
||||
Reference in New Issue
Block a user