Merge branch 'stack/agent-profiles-6-cordis-agent' into stack/agent-profiles-7-docs
This commit is contained in:
@@ -25,6 +25,12 @@ export interface AgentPresetOption {
|
|||||||
export interface AgentPresetSettingsState {
|
export interface AgentPresetSettingsState {
|
||||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
|
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
|
||||||
error: string | null
|
error: string | null
|
||||||
|
/**
|
||||||
|
* Whether this browser may persist the choice at all. `settings.describe` is
|
||||||
|
* loopback-only and reports a read-only provider as `writable: false`; the
|
||||||
|
* row then shows the current default and disables the control rather than
|
||||||
|
* offering a write the gateway will refuse.
|
||||||
|
*/
|
||||||
writable: boolean
|
writable: boolean
|
||||||
currentValue: string
|
currentValue: string
|
||||||
options: readonly AgentPresetOption[]
|
options: readonly AgentPresetOption[]
|
||||||
@@ -33,6 +39,8 @@ export interface AgentPresetSettingsState {
|
|||||||
const INITIAL: AgentPresetSettingsState = {
|
const INITIAL: AgentPresetSettingsState = {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
|
// Assumed until `load()` asks; a row that has not read yet renders nothing
|
||||||
|
// interactive anyway (status 'idle').
|
||||||
writable: true,
|
writable: true,
|
||||||
currentValue: '',
|
currentValue: '',
|
||||||
options: [],
|
options: [],
|
||||||
@@ -69,9 +77,15 @@ export class AgentPresetSettingsController {
|
|||||||
this.set({ status: 'unavailable', options: [], currentValue: '' })
|
this.set({ status: 'unavailable', options: [], currentValue: '' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The roster says what may be chosen; `settings.describe` says whether
|
||||||
|
// this browser may write the choice down. A non-loopback browser reaches
|
||||||
|
// neither method, so a refused describe leaves the row read-only rather
|
||||||
|
// than offering a control whose write answers `settings-not-exposed`.
|
||||||
|
const described = await this.api.settings.describe({})
|
||||||
this.set({
|
this.set({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
error: null,
|
error: null,
|
||||||
|
writable: described.result.ok && described.result.value.writable,
|
||||||
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
||||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
|
currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface Recorded { ns: string; patch: unknown }
|
|||||||
/** A client whose roster and write outcome the test controls. */
|
/** A client whose roster and write outcome the test controls. */
|
||||||
function fakeApi(
|
function fakeApi(
|
||||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||||
options: { writes?: Recorded[]; failWrite?: string; failList?: string } = {},
|
options: { writes?: Recorded[]; failWrite?: string; failList?: string; readOnly?: boolean } = {},
|
||||||
): IApiClient {
|
): IApiClient {
|
||||||
return {
|
return {
|
||||||
agentPresets: {
|
agentPresets: {
|
||||||
@@ -26,6 +26,15 @@ function fakeApi(
|
|||||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
|
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
// Loopback-only in production; a read-only provider answers writable:false
|
||||||
|
// and the row disables its control instead of offering a refused write.
|
||||||
|
describe: () => Promise.resolve({
|
||||||
|
rpcId: 'r',
|
||||||
|
result: {
|
||||||
|
ok: true as const,
|
||||||
|
value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
update: (payload: { ns: string; patch: unknown }) => {
|
update: (payload: { ns: string; patch: unknown }) => {
|
||||||
options.writes?.push({ ns: payload.ns, patch: payload.patch })
|
options.writes?.push({ ns: payload.ns, patch: payload.patch })
|
||||||
if (options.failWrite !== undefined) {
|
if (options.failWrite !== undefined) {
|
||||||
@@ -42,6 +51,20 @@ function fakeApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('the agent-preset settings controller', () => {
|
describe('the agent-preset settings controller', () => {
|
||||||
|
it('disables the control when this browser may not write settings', async () => {
|
||||||
|
const controller = new AgentPresetSettingsController(fakeApi([
|
||||||
|
{ id: 'standard', trust: 'system', isDefault: true },
|
||||||
|
], { readOnly: true }))
|
||||||
|
|
||||||
|
await controller.load()
|
||||||
|
|
||||||
|
// `settings.describe` is loopback-only and reports a read-only provider;
|
||||||
|
// offering a control whose write answers `settings-not-exposed` would
|
||||||
|
// promise a switch the host refuses.
|
||||||
|
expect(controller.store.getSnapshot().writable).toBe(false)
|
||||||
|
expect(controller.store.getSnapshot().currentValue).toBe('standard')
|
||||||
|
})
|
||||||
|
|
||||||
it('derives options and the current default from one roster call', async () => {
|
it('derives options and the current default from one roster call', async () => {
|
||||||
const controller = new AgentPresetSettingsController(fakeApi([
|
const controller = new AgentPresetSettingsController(fakeApi([
|
||||||
{ id: 'standard', trust: 'system', isDefault: true },
|
{ id: 'standard', trust: 'system', isDefault: true },
|
||||||
|
|||||||
@@ -319,12 +319,14 @@ async function summarizeCold(
|
|||||||
// a cold log to check for turns would defeat the index read, so a listed
|
// a cold log to check for turns would defeat the index read, so a listed
|
||||||
// cold session is served as not-blank (its log holds its conversation).
|
// cold session is served as not-blank (its log holds its conversation).
|
||||||
blank: false,
|
blank: false,
|
||||||
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
|
// The same projection the attached path uses. Hand-copying the header here
|
||||||
...meta.origin === undefined ? {} : { origin: meta.origin },
|
// is how `agentPreset` went missing from cold rows while `summarize()`
|
||||||
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
|
// served it — a restored session then read as preset-less and the picker
|
||||||
filters those out (legacy logs are not served); the conditional mirrors
|
// showed the deployment default instead of what the session runs. With no
|
||||||
summarize() shape. */
|
// events to read (a cold row never loads its log, see `blank` above), this
|
||||||
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
|
// resolves to the header's value; a switch recorded while blank surfaces
|
||||||
|
// once the session attaches.
|
||||||
|
...sessionListFields(meta),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,6 +747,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
const targets = new WeakMap<Agent, WebLlmTargetRef>()
|
const targets = new WeakMap<Agent, WebLlmTargetRef>()
|
||||||
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
||||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
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. */
|
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
|
||||||
const sessionCreations = new Map<SessionId, Promise<Agent>>()
|
const sessionCreations = new Map<SessionId, Promise<Agent>>()
|
||||||
/** Serializes path ownership and explicit title checks with Workspace mutations. */
|
/** Serializes path ownership and explicit title checks with Workspace mutations. */
|
||||||
@@ -1119,7 +1129,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
const handle = await ctx.agents.resume({
|
const handle = await ctx.agents.resume({
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions,
|
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
|
return handle.agent
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2528,39 +2545,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
const found = await agentFor(sessionId)
|
const found = await agentFor(sessionId)
|
||||||
if ('error' in found) return err(request, found.error)
|
if ('error' in found) return err(request, found.error)
|
||||||
const { agent } = found
|
const { agent } = found
|
||||||
if (!sessionBlank(agent.session)) {
|
const swap = async (): Promise<RpcResponse<{ agentPreset: string }>> => {
|
||||||
return err(request, {
|
// Re-read inside the queue: an earlier switch may have run, and a
|
||||||
code: 'agent-preset-locked',
|
// conversation may have started, since this request arrived.
|
||||||
message: `session "${sessionId}" has already started; its agent preset is fixed`,
|
if (!sessionBlank(agent.session)) {
|
||||||
details: { sessionId, agentPreset },
|
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 {
|
try {
|
||||||
const preset = await presets.recompose(agent.ctx, agentPreset)
|
return await turn
|
||||||
// Recorded only after the swap committed: the log states what the
|
} finally {
|
||||||
// agent runs, and a rejected mount leaves the previous composition.
|
if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId)
|
||||||
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: {},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ export interface AgentPresetEntry {
|
|||||||
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
||||||
export interface AgentPresetsApi {
|
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
|
* An empty roster means the deployment composes no presets at all, and
|
||||||
* every session shares the host composition.
|
* every session shares the host composition.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
|||||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
||||||
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
import { resolveSessionPreset, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
||||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||||
import { createApiProxy } from '../src/api-proxy.ts'
|
import { createApiProxy } from '../src/api-proxy.ts'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
@@ -280,6 +280,46 @@ describe('agentPreset.select', () => {
|
|||||||
expect(response.result.value.agentPreset).toBe('core-web')
|
expect(response.result.value.agentPreset).toBe('core-web')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 () => {
|
it('refuses once the conversation has started', async () => {
|
||||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||||
await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
|
await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
|
||||||
|
|||||||
@@ -190,11 +190,16 @@ export class AgentPresets extends Service {
|
|||||||
try {
|
try {
|
||||||
await mountPreset(agentCtx, preset)
|
await mountPreset(agentCtx, preset)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (previous !== undefined && previous !== preset.id) {
|
if (previous !== undefined) {
|
||||||
|
// Restored unconditionally, same id included: the roster is a live
|
||||||
|
// directory, so "the same inputs that worked a moment ago" does not
|
||||||
|
// hold — the file may have changed between the original mount and
|
||||||
|
// this one, which is exactly how a same-id reselect fails. Skipping
|
||||||
|
// the restore there left the agent with no composition at all.
|
||||||
await this.mount(agentCtx, previous).catch(() => {
|
await this.mount(agentCtx, previous).catch(() => {
|
||||||
// The agent now has no composition, but the switch failure below is
|
// The agent now has no composition, but the switch failure below is
|
||||||
// the actionable diagnostic and the restore had the same inputs that
|
// the actionable diagnostic; reporting the restore's instead would
|
||||||
// worked a moment ago; reporting its failure instead would hide why.
|
// hide why the switch was attempted and what the operator must fix.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
Reference in New Issue
Block a user