Files
deepseek-harness/packages/subagent/subagent/src/descriptor.ts

214 lines
8.8 KiB
TypeScript

/**
* The durable continuable-child descriptor: the versioned, model-hidden
* `subagent/descriptor` session event that records a child's declared
* composition so a known child id can be cold-resumed after its run — and its
* process — are gone. Providers append it turn-enclosed in the child's initial
* turn; the continuation manager folds it back on resume.
*
* The descriptor deliberately snapshots explicit fields rather than the
* merge-extensible `AgentOptions` object: an unrelated extension value cannot
* make continuation fail merely because it is not JSON, and later composition
* inputs require a deliberate {@link SUBAGENT_DESCRIPTOR_VERSION} change. It
* omits `subagentDepth` — cold resume trusts the persisted header's
* `delegationDepth` as the monotone floor — and `outputSchema`, which belongs
* to one activation's result contract rather than durable child composition.
*
* @module @deepseek-ai/dsh-subagent/descriptor
*/
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Durable declared composition of a continuable subagent child, appended
* once by the establishing provider inside the child's initial turn,
* before its first request. Log-only: it carries no `surfaceOp`, never
* enters model history, and the append-only log retains it when
* compaction replaces surface history.
*/
'subagent/descriptor': SubagentDescriptorData
}
}
/**
* The current descriptor format version, stamped into every appended
* `subagent/descriptor` event and required verbatim by {@link foldSubagentDescriptor}.
* Supporting another composition input is a deliberate version change, never
* an implicit extra field.
*/
export const SUBAGENT_DESCRIPTOR_VERSION = 1
/** The `subagent/descriptor` event payload — a continuable child's declared composition. */
export interface SubagentDescriptorData {
/** Descriptor format version ({@link SUBAGENT_DESCRIPTOR_VERSION}). */
readonly version: number
/** The `ctx.subagents` provider name that established the child. */
readonly provider: string
/** Resolved child `agentOptions.provider`, when one was declared. */
readonly agentProvider?: string
/** Resolved child `agentOptions.model`, when one was declared. */
readonly agentModel?: string
/** Per-child persona that shadows the deployment persona on resume. */
readonly persona?: string
/** Child tool scoping reapplied on resume. */
readonly toolFilter?: ToolRestriction
}
/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */
export interface SubagentDescriptorInput {
/** The `ctx.subagents` provider name that will establish the child. */
readonly provider: string
/** Requested child `agentOptions.provider`. */
readonly agentProvider?: string
/** Requested child `agentOptions.model`. */
readonly agentModel?: string
/** Requested per-child persona. */
readonly persona?: string
/** Requested child tool scoping. */
readonly toolFilter?: ToolRestriction
}
const DESCRIPTOR_KEYS = new Set([
'version',
'provider',
'agentProvider',
'agentModel',
'persona',
'toolFilter',
])
const TOOL_FILTER_KEYS = new Set(['allow', 'deny'])
/** Whether a persisted JSON value is an object record. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Reject fields outside one versioned record's declared schema. */
function assertKnownKeys(value: Record<string, unknown>, keys: ReadonlySet<string>, path: string): void {
const unknown = Object.keys(value).find(key => !keys.has(key))
if (unknown !== undefined) {
throw new Error(`persisted subagent descriptor ${path} has unknown field "${unknown}"`)
}
}
/** Read one optional string field from a persisted descriptor record. */
function optionalString(value: Record<string, unknown>, key: string): string | undefined {
if (!Object.hasOwn(value, key)) return undefined
const field = value[key]
if (typeof field !== 'string') {
throw new Error(`persisted subagent descriptor ${key} must be a string`)
}
return field
}
/** Read one optional string-array field from a persisted tool restriction. */
function optionalStringArray(value: Record<string, unknown>, key: string): string[] | undefined {
if (!Object.hasOwn(value, key)) return undefined
const field = value[key]
if (!Array.isArray(field)) {
throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`)
}
const items: unknown[] = field
if (items.some(item => typeof item !== 'string')) {
throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`)
}
return items as string[]
}
/** Validate and reconstruct a persisted tool restriction. */
function parseToolFilter(value: unknown): ToolRestriction {
if (!isRecord(value)) {
throw new Error('persisted subagent descriptor toolFilter must be an object')
}
assertKnownKeys(value, TOOL_FILTER_KEYS, 'toolFilter')
const allow = optionalStringArray(value, 'allow')
const deny = optionalStringArray(value, 'deny')
if (allow === undefined && deny === undefined) {
throw new Error('persisted subagent descriptor toolFilter must declare allow and/or deny')
}
return {
...allow !== undefined ? { allow } : {},
...deny !== undefined ? { deny } : {},
}
}
/** Validate one persisted descriptor payload for the current runtime. */
function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undefined {
if (!isRecord(value)) {
throw new Error('persisted subagent descriptor payload must be an object')
}
const version = value['version']
if (typeof version !== 'number') {
throw new Error('persisted subagent descriptor version must be a number')
}
if (version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined
assertKnownKeys(value, DESCRIPTOR_KEYS, 'payload')
const provider = value['provider']
if (typeof provider !== 'string') {
throw new Error('persisted subagent descriptor provider must be a string')
}
const agentProvider = optionalString(value, 'agentProvider')
const agentModel = optionalString(value, 'agentModel')
const persona = optionalString(value, 'persona')
const toolFilter = Object.hasOwn(value, 'toolFilter')
? parseToolFilter(value['toolFilter'])
: undefined
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider,
...agentProvider !== undefined ? { agentProvider } : {},
...agentModel !== undefined ? { agentModel } : {},
...persona !== undefined ? { persona } : {},
...toolFilter !== undefined ? { toolFilter } : {},
}
}
/**
* Validate and detach descriptor inputs into the durable payload, before any
* Task or provider work begins — the same detached lossless-JSON boundary the
* session log itself enforces, applied early so a synchronous validation
* failure rejects the tool call without creating a Task.
* @param input - the caller-collected composition fields.
* @returns the versioned, detached descriptor payload.
* @throws when a field is not losslessly JSON-serializable.
*/
export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData {
const candidate: SubagentDescriptorData = {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: input.provider,
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
}
const snapshot = snapshotJsonValue(candidate)
if (snapshot === undefined) {
throw new Error('subagent descriptor is not losslessly JSON-serializable')
}
return snapshot
}
/**
* Fold a persisted child log to its supported descriptor. The first
* `subagent/descriptor` event is authoritative — the establishing provider
* appends exactly one, so a later same-type event cannot rewrite the declared
* composition.
* @param events - the loaded child session events.
* @returns the descriptor, or `undefined` when the log has none or its
* version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child is not
* resumable by this runtime).
* @throws when a current-version persisted payload does not match its complete
* declared schema.
*/
export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined {
const event = events.find(
(candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor',
)
if (event === undefined) return undefined
return parseSubagentDescriptor(event.data)
}