mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-30 04:40:37 +00:00
415 lines
16 KiB
TypeScript
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)
|
|
}
|
|
}
|