Files
deepseek-harness/packages/api/session-controller/src/agent.ts
T

415 lines
16 KiB
TypeScript

/** Agent activation, composition, and model-selection policy owned by API Session. */
import { mkdir } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
import type { SessionError } from './types.ts'
/** Cold Session identity absent from persistence. */
export class ApiSessionNotFound extends Error {}
/** Session identity whose lifecycle belongs to subagent routing. */
export class ApiSessionSubagentOwnership extends Error {
/** @param sessionId - identity reserved to subagent routing. */
constructor(readonly sessionId: SessionId) {
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
}
}
/** Explicit-id creation attempted to adopt a Session under another cwd. */
export class ApiSessionCwdConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedCwd: string,
readonly existingCwd: string | undefined,
) {
super(
existingCwd === undefined
? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"`
: `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`,
)
}
}
/** Explicit-id creation attempted to adopt a Session under another preset. */
export class ApiSessionPresetConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedPreset: string,
readonly existingPreset: string | undefined,
) {
super(
existingPreset === undefined
? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`
: `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`,
)
}
}
/** Failures produced while resolving one ordinary Session identity to its live Agent. */
export type ApiSessionAgentError = Extract<
SessionError,
{ readonly code: 'session-not-found' | 'agent-busy' | 'internal' }
>
/** Result of resolving one ordinary Session identity to its live Agent. */
export type ApiSessionAgentResult =
| { readonly agent: Agent }
| { readonly error: ApiSessionAgentError }
type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection }
/**
* Test whether generic Session routing must leave an identity to subagent routing.
* @param ctx - Host context carrying the Agent ownership registry.
* @param session - attached or live Session whose ownership is tested.
* @param agent - live Agent when one exists for the Session.
* @returns whether subagent routing owns the Session identity.
*/
export function hasApiSessionSubagentOwner(
ctx: Context,
session: Pick<Session, 'header'>,
agent: Agent | undefined,
): boolean {
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}
/**
* Build the stable caller-facing subagent ownership rejection.
* @param sessionId - Session identity owned by subagent routing.
* @returns a stable Session-domain failure.
*/
export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError {
return {
code: 'agent-busy',
message: `session "${sessionId}" is owned by subagent routing`,
details: { reason: 'use subagent delivery for this child session' },
}
}
/**
* Inspect one cold Session without repairing, resuming, or publishing it.
* @param ctx - Host context carrying Session persistence.
* @param sessionId - durable Session identity.
* @param signal - optional cancellation for persistence reads.
* @returns the persisted header and complete event prefix.
*/
export async function inspectApiSession(
ctx: Context,
sessionId: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
}
const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
if (meta === undefined || meta.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
const inspected = await persistence.inspect(sessionId, signal)
if (inspected.meta.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
return { meta: inspected.meta, events: [...inspected.events] }
}
/** Owns every operation that may create, resume, or configure a Web Agent. */
export class ApiSessionAgentController {
private readonly resumes = new Map<SessionId, Promise<Agent>>()
private readonly creations = new Map<SessionId, Promise<Agent>>()
private readonly selections = new WeakMap<Agent, InstalledSelection>()
private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
/** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */
constructor(private readonly ctx: Context) {
ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
return found.agent
})
ctx.typert.lookups.configure('session', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
return found.agent.session
})
ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
return found.agent.ctx
})
}
/**
* Resolve or resume one ordinary Session, deduplicating concurrent resumes.
* @param sessionId - ordinary Session identity.
* @returns the live Agent or a stable Session-domain failure.
*/
async resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
const live = this.liveAgent(sessionId)
if (live !== undefined) return live
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
return { error: apiSessionSubagentOwnershipError(sessionId) }
}
let resume = this.resumes.get(sessionId)
if (resume === undefined) {
resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) })
this.resumes.set(sessionId, resume)
}
try {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof ApiSessionNotFound) {
return {
error: {
code: 'session-not-found',
message: error.message,
details: { sessionId },
},
}
}
if (error instanceof ApiSessionSubagentOwnership) {
return { error: apiSessionSubagentOwnershipError(error.sessionId) }
}
const raced = this.liveAgent(sessionId)
if (raced !== undefined) return raced
const racedSession = this.ctx.sessions.get(sessionId)
if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
return { error: apiSessionSubagentOwnershipError(sessionId) }
}
return {
error: {
code: 'internal',
message: `resume failed for session "${sessionId}": ${String(error)}`,
details: {},
},
}
}
}
/**
* Resolve one requested identity, creating or resuming it once.
* @param sessionId - requested Session identity.
* @param cwd - directory the Session must own.
* @param checkPersistedIdentity - whether to inspect a cold identity before creation.
* @param presetId - optional Agent preset the Session must own.
* @returns the matching live ordinary Agent.
*/
async ensureSession(
sessionId: SessionId,
cwd: string,
checkPersistedIdentity: boolean,
presetId?: string,
): Promise<Agent> {
let creation = this.creations.get(sessionId)
if (creation === undefined) {
creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId)
.catch((error: unknown) => {
const live = this.ctx.agents.get(sessionId)
if (live !== undefined) {
if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
return live
}
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
throw error
})
.finally(() => { this.creations.delete(sessionId) })
this.creations.set(sessionId, creation)
}
const agent = await creation
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
if (agent.session.header.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
return agent
}
/**
* Install or return the Session-local model selection used by prompt assembly.
* @param agent - live Agent that owns the selection.
* @returns the installed mutable selection reference.
*/
selectionFor(agent: Agent): InstalledSelection {
const installed = this.selections.get(agent)
if (installed !== undefined) return installed
let picked: AgentModelSelection | undefined
const defaultModel = this.ctx.agentDefaultModel
const selection: InstalledSelection = {
get current(): AgentModelSelection {
if (picked !== undefined) return picked
const logged = agent.session.requestHeader()?.config
if (logged === undefined) return defaultModel.currentSelection()
return {
provider: logged.provider,
model: logged.model,
...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }),
}
},
set current(next: AgentModelSelection) {
picked = next
},
assembled: undefined,
}
installModelSelection(agent.ctx, selection)
this.selections.set(agent, selection)
return selection
}
/**
* Serialize image admission and model selection for one Agent.
* @param agent - live Agent that owns the serialization chain.
* @param operation - asynchronous operation admitted after prior work settles.
* @returns the operation result or rejection.
*/
serializeImageAdmission<Value>(agent: Agent, operation: () => Promise<Value>): Promise<Value> {
const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
this.imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
return result
}
/**
* Resolve the preset id and pre-publication Agent setup for a create or resume.
* @param presetId - requested preset or the configured default when omitted.
* @returns the resolved preset identity and Agent setup callback.
*/
async composeAgent(presetId: string | undefined): Promise<{
readonly agentPreset?: string
readonly setup: AgentSetup
}> {
const presets = this.ctx.get('agentPresets')
if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } }
const resolvedId = (await presets.resolve(presetId)).id
return {
agentPreset: resolvedId,
setup: async (agentCtx) => {
this.installSelection(agentCtx)
await presets.mount(agentCtx, resolvedId)
},
}
}
private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined {
const agent = this.ctx.agents.get(sessionId)
if (agent === undefined) return undefined
return hasApiSessionSubagentOwner(this.ctx, agent.session, agent)
? { error: apiSessionSubagentOwnershipError(sessionId) }
: { agent }
}
private async resume(sessionId: SessionId): Promise<Agent> {
const inspected = await inspectApiSession(this.ctx, sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
const composition = await this.composeAgent(resolveSessionPreset({
header: inspected.meta,
events: inspected.events,
}))
const published = this.ctx.sessions.get(sessionId)
const live = this.ctx.agents.get(sessionId)
if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
return (await this.ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: this.agentOptions(),
setup: composition.setup,
})).agent
}
private async createOrAdopt(
sessionId: SessionId,
cwd: string,
checkPersistedIdentity: boolean,
presetId: string | undefined,
): Promise<Agent> {
const attached = this.ctx.sessions.get(sessionId)
const live = this.ctx.agents.get(sessionId)
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (live !== undefined) return live
const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined
const stored = persistence === undefined
? undefined
: (await persistence.list()).find(header => header.id === sessionId)
if (persistence !== undefined && stored !== undefined) {
const inspected = await persistence.inspect(sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (inspected.meta.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
}
const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
this.assertPresetUnchanged(sessionId, presetId, storedPreset)
const composition = await this.composeAgent(storedPreset)
return (await this.ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: this.agentOptions(),
setup: composition.setup,
})).agent
}
try {
await mkdir(cwd, { recursive: true })
} catch (error: unknown) {
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
}
const composition = await this.composeAgent(presetId)
return (await this.ctx.agents.create({
sessionId,
agentOptions: this.agentOptions(),
meta: {
cwd,
...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
},
setup: composition.setup,
})).agent
}
private agentOptions(): AgentOptions {
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
return { provider, model }
}
private installSelection(agentCtx: Context): void {
const agent = agentCtx.agent
if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent')
this.selectionFor(agent)
}
private assertPresetUnchanged(
sessionId: SessionId,
requested: string | undefined,
existing: string | undefined,
): void {
if (requested === undefined || requested === existing) return
throw new ApiSessionPresetConflict(sessionId, requested, existing)
}
}