feat(web): compose a web session's agent from a named preset
`session.create` takes an optional `agentPreset`, and the host resolves it, mounts it during pre-publication setup, and records the resolved id on the session header so a later resume rebuilds the same agent. Resolution happens BEFORE the session exists, not inside setup: the session boundary snapshots `meta` before asynchronous setup begins, so an id discovered during setup could never reach the header. Mounting still happens in setup, where a failure rolls the whole creation back rather than publishing a session whose capabilities are half-installed. Resume ignores whatever the request names and rebuilds from the stored id. A resumed session's history was produced under that composition; restoring a different one would replay tool calls the model can no longer make. `dsh-agent-presets` now throws `UnknownPresetError` / `PresetMountError` so the host can tell a bad request from a broken preset — they become `agent-preset-not-found` and `agent-preset-invalid`. Ships the two built-in compositions (`standard`, `core-web`) and the persona row that lets them differ in identity. Nothing mounts them yet: no roster is configured, so `composeAgent` finds no service and every session keeps the host composition. Wiring the roster and moving base's agent-plane rows behind it is the next commit, so the switch happens atomically with a real-composition test.
This commit is contained in:
@@ -63,13 +63,15 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
"@deepseek-ai/dsh-agent-presets": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
|
||||
@@ -744,6 +745,45 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
targetFor(agent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preset an agent will be composed from, and the setup that
|
||||
* installs it.
|
||||
*
|
||||
* The id is resolved BEFORE the session exists because the session boundary
|
||||
* snapshots `meta` before asynchronous setup begins — a preset discovered
|
||||
* during setup could never reach the header. Mounting still happens in
|
||||
* setup, where a failure rolls the whole creation back rather than leaving a
|
||||
* published session whose capabilities are half-installed.
|
||||
*
|
||||
* A deployment with no preset roster composes nothing and every session
|
||||
* shares the host composition, which is the behavior before presets existed.
|
||||
* @param presetId - the requested preset, or `undefined` for the default.
|
||||
* @returns the id to record on the header (absent without a roster) and the setup callback.
|
||||
* @throws when the roster supplies no such preset.
|
||||
*/
|
||||
async function composeAgent(presetId: string | undefined): Promise<{
|
||||
agentPreset?: string
|
||||
setup: (agentCtx: Context) => Promise<void>
|
||||
}> {
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) {
|
||||
return {
|
||||
setup: (agentCtx: Context) => {
|
||||
installTarget(agentCtx)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}
|
||||
const resolvedId = (await presets.resolve(presetId)).id
|
||||
return {
|
||||
agentPreset: resolvedId,
|
||||
setup: async (agentCtx: Context) => {
|
||||
installTarget(agentCtx)
|
||||
await presets.mount(agentCtx, resolvedId)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Send one transient frame to every connected mux consumer. */
|
||||
function broadcast(payload: MuxFrame): void {
|
||||
const envelope = frame(payload)
|
||||
@@ -1101,7 +1141,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
|
||||
/** Resolve one requested identity to a live agent, creating or resuming it once. */
|
||||
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
|
||||
async function ensureSession(
|
||||
sessionId: SessionId,
|
||||
cwd: string,
|
||||
checkPersistedIdentity: boolean,
|
||||
presetId?: string,
|
||||
): Promise<Agent> {
|
||||
let creation = sessionCreations.get(sessionId)
|
||||
if (creation === undefined) {
|
||||
creation = (async () => {
|
||||
@@ -1127,10 +1172,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (inspected.meta.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
|
||||
}
|
||||
// The stored preset wins over anything the request names: a resumed
|
||||
// session's history was produced under that composition, and
|
||||
// rebuilding it differently would replay tool calls the model can no
|
||||
// longer make.
|
||||
return (await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions,
|
||||
setup: installTarget,
|
||||
setup: (await composeAgent(inspected.meta.agentPreset)).setup,
|
||||
})).agent
|
||||
}
|
||||
|
||||
@@ -1139,11 +1188,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
|
||||
}
|
||||
const composition = await composeAgent(presetId)
|
||||
return (await ctx.agents.create({
|
||||
sessionId,
|
||||
agentOptions,
|
||||
meta: { cwd },
|
||||
setup: installTarget,
|
||||
meta: {
|
||||
cwd,
|
||||
...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
|
||||
},
|
||||
setup: composition.setup,
|
||||
})).agent
|
||||
})().catch((error: unknown) => {
|
||||
// Another Host entry path may have published the same identity while
|
||||
@@ -1592,9 +1645,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}
|
||||
const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
|
||||
const requestedPreset = request.payload.agentPreset
|
||||
try {
|
||||
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
|
||||
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
|
||||
} 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 },
|
||||
})
|
||||
}
|
||||
if (error instanceof SessionCwdConflict) {
|
||||
return err(request, {
|
||||
code: 'session-conflict',
|
||||
|
||||
@@ -46,6 +46,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = 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-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() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-exists': { path: string }
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-preset-not-found': { agentPreset: string; available: string[] }
|
||||
'agent-preset-invalid': { agentPreset: string; reason: string }
|
||||
'agent-busy': { reason: string }
|
||||
'queue-item-not-found': { itemId: MessageId }
|
||||
'steer-unavailable': { itemId: MessageId }
|
||||
|
||||
@@ -100,6 +100,7 @@ export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
sessionId: sessionIdSchema.optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
}).refine(
|
||||
payload => payload.workspaceId === undefined || payload.cwd === undefined,
|
||||
{ message: 'session.create accepts workspaceId or cwd, not both' },
|
||||
|
||||
@@ -199,8 +199,14 @@ export interface SessionsApi {
|
||||
* session, while a different cwd fails with `session-conflict`. Workspace
|
||||
* creation attaches the session after publication; an attach failure
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*
|
||||
* `agentPreset` names the composition the new session's agent is built
|
||||
* from; omitted, the deployment's default preset applies. The resolved id
|
||||
* is stored on the session header, so a later resume rebuilds the same
|
||||
* agent. An unknown id fails with `agent-preset-not-found`, and a preset
|
||||
* whose composition cannot be mounted fails with `agent-preset-invalid`.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user