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:
Yichen Jiang
2026-08-03 22:38:11 +08:00
parent 739042a804
commit 91b55b9245
22 changed files with 458 additions and 39 deletions
+6 -4
View File
@@ -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"
}
}
+73 -5
View File
@@ -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() }) }),
+2
View File
@@ -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' },
+7 -1
View File
@@ -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 }>>
/**
+3
View File
@@ -32,6 +32,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../preset/agent-presets"
},
{
"path": "../../core/session"
},