Files
deepseek-harness/packages/api/session-controller/src/index.ts
T
2026-08-25 06:19:23 +08:00

315 lines
11 KiB
TypeScript

/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
ApiSessionAgentController,
inspectApiSession,
type ApiSessionAgentResult,
} from './agent.ts'
import { SessionCommandController } from './commands.ts'
import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
import { installModelSelectionProjection } from './model-selection-projection.ts'
import type {
SessionAttachmentRequest,
SessionAttachmentValue,
SessionCancelRequest,
SessionCancelValue,
SessionControlFrame,
SessionCreateRequest,
SessionCreateValue,
SessionFollowFrame,
SessionFollowRequest,
SessionForkRequest,
SessionForkValue,
SessionListRequest,
SessionListValue,
SessionPage,
SessionPageRequest,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
SessionRenameValue,
SessionSearchRequest,
SessionSearchValue,
SessionSelectModelRequest,
SessionSelectModelValue,
SessionUpdateQueueRequest,
SessionUpdateQueueValue,
} from './types.ts'
export type * from './types.ts'
export { ApiSessionNotFound } from './agent.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host Session business API and Remote namespace owner. */
sessionController: SessionController
}
}
/** Session Controller deployment policy. */
export interface Config {
/** Maximum cold Session artifact size eligible for one full projection observation. */
readonly coldBlankProbeMaxBytes?: number
}
/** Host service backing the generated `ctx.remote.session` namespace. */
export class SessionController extends TypertRemoteService {
static inject = [
'agentDefaultModel',
'agents',
'attachments',
'llm',
'sessions',
'sessionProjections',
'sessionQuery',
'typert',
'workspaceRegistry',
]
static Config: z<Config> = z.object({
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
})
private readonly agents: ApiSessionAgentController
private readonly commands: SessionCommandController
private readonly controlState: SessionControlController
private readonly history: SessionHistoryController
private readonly listState: ApiSessionList
private readonly promotions = new Set<Promise<void>>()
/**
* @param ctx - Host context containing the Session capability assembly.
* @param config - cold-list observation policy.
*/
constructor(ctx: Context, config: Config) {
super(ctx, 'sessionController', { namespace: 'session' })
installModelSelectionProjection(ctx)
this.agents = new ApiSessionAgentController(ctx)
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
this.controlState = new SessionControlController(ctx)
// Registered before history so reverse-order teardown closes every
// follower before waiting for already-admitted promotions.
ctx.effect(() => async () => {
await Promise.allSettled([...this.promotions])
}, 'session-controller.promotions')
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
this.listState = new ApiSessionList(
ctx,
config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
)
ctx.on('session/created', (session) => {
ctx.emit('api-session/added', this.listState.summaryFor(session))
})
ctx.on('session/disposed', (session) => {
ctx.emit('api-session/removed', session.id)
})
ctx.on('agent/status', ({ agent, status }) => {
ctx.emit('api-session/status', agent.id, status === 'running')
})
ctx.on('agent/error', ({ agent, error }) => {
ctx.emit('api-session/error', agent.id, errorChain(error))
})
ctx.on('session/event', (session, event) => {
if (event.type === 'request/header') {
const agent = ctx.agents.get(session.id)
if (agent?.session === session) this.agents.consumeSelection(
agent,
event.data.header.config.provider,
event.data.header.config.model,
event.data.header.config.reasoningEffort,
)
}
if (event.type !== 'user/message' || event.data.source.kind !== 'user') return
ctx.emit('api-session/activity', session.id, event.time)
})
}
private promote(observation: SessionObservation): void {
const sessionId = observation.header.id
const task = (async () => {
using ownedObservation = observation
const result = await this.agents.resolveObservedAgent(ownedObservation)
if ('error' in result) this.ctx.emit('api-session/error', sessionId, result.error.message)
})().catch((error: unknown) => {
this.ctx.logger.error(`session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`)
})
this.promotions.add(task)
void task.finally(() => { this.promotions.delete(task) })
}
/**
* Resolve or resume one ordinary Session for another Host API domain.
* @param sessionId - Session identity whose Agent owns the operation.
* @returns the live Agent or the stable Session-domain failure.
*/
resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
return this.agents.resolveAgent(sessionId)
}
/**
* Inspect one attached or persisted Session without activating its Agent.
* @param sessionId - durable Session identity.
* @param signal - optional caller cancellation for persistence reads.
* @returns the current attached state or persisted header and event prefix.
*/
inspect(
sessionId: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined) {
return Promise.resolve({ meta: attached.header, events: [...attached.events] })
}
return inspectApiSession(this.ctx, sessionId, signal)
}
/**
* Read all visible Session rows without resuming an Agent.
* @param _request - reserved empty list request.
* @param signal - cancellation for persistence reads.
* @returns visible Session summaries ordered by activity.
*/
@Remote('list')
async list(_request: SessionListRequest, signal: AbortSignal): Promise<SessionListValue> {
return { items: await this.listState.list(signal) }
}
/**
* Search visible Session content without resuming an Agent.
* @param request - literal message-content query.
* @param signal - cancellation for list and search reads.
* @returns authorized bounded Session search results.
*/
@Remote('search')
search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
return this.listState.search(request.query, signal)
}
/**
* Create or idempotently adopt one ordinary Session.
* @param request - requested identity, location, and Agent preset.
* @returns the Session identity and resolved preset when configured.
*/
@Remote('create')
create(request: SessionCreateRequest): Promise<SessionCreateValue> {
return this.commands.create(request)
}
/**
* Select one Session-local model after explicitly resuming the Session.
* @param request - Session identity and requested model selection.
* @returns the normalized selection installed for the Session.
*/
@Remote('selectModel')
selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
return this.commands.selectModel(request)
}
/**
* Rename one Session after explicitly resuming it.
* @param request - Session identity and proposed title.
* @returns the accepted title and durable event sequence.
*/
@Remote('rename')
rename(request: SessionRenameRequest): Promise<SessionRenameValue> {
return this.commands.rename(request)
}
/**
* Fork one cold-readable completed-turn prefix into a new Session.
* @param request - source Session and optional event anchor.
* @returns the new Session identity.
*/
@Remote('fork')
fork(request: SessionForkRequest): Promise<SessionForkValue> {
return this.commands.fork(request)
}
/**
* Admit one prompt after explicitly resuming its Session.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation before prompt admission begins.
* @returns acknowledgement that the Agent accepted the prompt.
*/
@Remote('prompt')
prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
signal.throwIfAborted()
return this.commands.prompt(request)
}
/**
* Read one image proven reachable from the addressed Session log.
* @param request - Session and attachment identities used for authorization.
* @returns the durable attachment reference and base64-encoded bytes.
*/
@Remote('attachment')
attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
return this.commands.attachment(request)
}
/**
* Mutate one still-pending queue occurrence on a live Agent.
* @param request - Session, queue item, and requested mutation.
* @returns acknowledgement that the queue mutation was applied.
*/
@Remote('updateQueue')
updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
return this.commands.updateQueue(request)
}
/**
* Cancel one active Agent turn without dropping its pending inbox.
* @param request - Session whose active Agent turn is cancelled.
* @returns acknowledgement that cancellation was requested.
*/
@Remote('cancel')
cancel(request: SessionCancelRequest): SessionCancelValue {
return this.commands.cancel(request)
}
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
* @param signal - cancellation for persistence reads.
* @returns one chronological page.
*/
@Remote('page')
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
return this.history.page(request, signal)
}
/**
* Follow one Session log from its opening or resume cursor.
* @param request - durable address and last committed sequence already held by the caller.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns a complete opening snapshot followed by gap-free event frames.
*/
@Remote({ mode: 'stream' })
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
return this.history.follow(request, signal)
}
/**
* Stream a complete live-control baseline followed by replacement frames.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns one complete baseline followed by live replacement frames.
*/
@Remote({ mode: 'stream' })
control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
return this.controlState.control(signal)
}
}
export { buildModelCatalog } from './catalog.ts'
export default SessionController