315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
/**
|
|
* The durable subagent-child descriptor: the versioned, model-hidden
|
|
* `subagent/descriptor` session event that identifies every session-backed
|
|
* subagent and records whether it is one-shot or continuable. Continuable
|
|
* descriptors additionally preserve the declared composition required for
|
|
* cold resume. Providers append it turn-enclosed in the child's initial turn.
|
|
*
|
|
* 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.
|
|
* Per-activation knobs such as `maxTokens` are omitted for the same reason as
|
|
* `outputSchema`: they budget one activation. Cold resume requires the exact
|
|
* live parent for authorization but reconstructs child options only from the
|
|
* durable descriptor, so it neither restores the prior budget nor inherits
|
|
* the parent's current one; the resumed route's defaults apply instead.
|
|
*
|
|
* @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/types' {
|
|
interface SessionEventMap {
|
|
/**
|
|
* Durable identity and lifecycle mode of a session-backed subagent child,
|
|
* appended once by the establishing provider inside the child's initial
|
|
* turn, before its first request. Continuable records also carry their
|
|
* resumable composition. Log-only: it carries no `surfaceOp`, never enters
|
|
* model history, and survives compaction.
|
|
*/
|
|
'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 = 2
|
|
|
|
/** Fields shared by every supported `subagent/descriptor` payload. */
|
|
interface SubagentDescriptorBase {
|
|
/** Descriptor format version ({@link SUBAGENT_DESCRIPTOR_VERSION}). */
|
|
readonly version: number
|
|
/** Whether the child is a terminal one-shot run or a resumable conversation. */
|
|
readonly mode: 'one-shot' | 'continuable'
|
|
/** The `ctx.subagents` provider name that established the child. */
|
|
readonly provider: string
|
|
}
|
|
|
|
/** A session-backed subagent that cannot be cold-resumed after its run. */
|
|
export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {
|
|
readonly mode: 'one-shot'
|
|
/**
|
|
* The initial delegation's short `description`, kept as the child's durable
|
|
* creation label so enumeration can identify the conversation without
|
|
* replaying parent tool results or exposing the child prompt.
|
|
*/
|
|
readonly label?: string
|
|
}
|
|
|
|
/** A session-backed subagent whose declared composition supports cold resume. */
|
|
export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {
|
|
readonly mode: 'continuable'
|
|
/** The initial delegation's short `description`, used for durable enumeration. */
|
|
readonly label: 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
|
|
}
|
|
|
|
/** The supported durable subagent identity and optional continuation composition. */
|
|
export type SubagentDescriptorData =
|
|
| OneShotSubagentDescriptorData
|
|
| ContinuableSubagentDescriptorData
|
|
|
|
/** Fields shared by descriptor snapshot inputs. */
|
|
interface SubagentDescriptorInputBase {
|
|
/** Whether the child is a terminal one-shot run or a resumable conversation. */
|
|
readonly mode: 'one-shot' | 'continuable'
|
|
/** The `ctx.subagents` provider name that will establish the child. */
|
|
readonly provider: string
|
|
}
|
|
|
|
/** Input for a one-shot child's durable identity. */
|
|
export interface OneShotSubagentDescriptorInput extends SubagentDescriptorInputBase {
|
|
readonly mode: 'one-shot'
|
|
/** Optional initial delegation `description` used as the durable creation label. */
|
|
readonly label?: string
|
|
}
|
|
|
|
/** Input for a continuable child's durable identity and resumable composition. */
|
|
export interface ContinuableSubagentDescriptorInput extends SubagentDescriptorInputBase {
|
|
readonly mode: 'continuable'
|
|
/** Initial delegation `description` used for durable enumeration. */
|
|
readonly label: 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
|
|
}
|
|
|
|
/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */
|
|
export type SubagentDescriptorInput =
|
|
| OneShotSubagentDescriptorInput
|
|
| ContinuableSubagentDescriptorInput
|
|
|
|
const DESCRIPTOR_BASE_KEYS = [
|
|
'version',
|
|
'mode',
|
|
'provider',
|
|
'label',
|
|
] as const
|
|
const ONE_SHOT_DESCRIPTOR_KEYS = new Set(DESCRIPTOR_BASE_KEYS)
|
|
const CONTINUABLE_DESCRIPTOR_KEYS = new Set([
|
|
...DESCRIPTOR_BASE_KEYS,
|
|
'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
|
|
|
|
const mode = value['mode']
|
|
if (mode !== 'one-shot' && mode !== 'continuable') {
|
|
throw new Error('persisted subagent descriptor mode must be "one-shot" or "continuable"')
|
|
}
|
|
assertKnownKeys(
|
|
value,
|
|
mode === 'one-shot' ? ONE_SHOT_DESCRIPTOR_KEYS : CONTINUABLE_DESCRIPTOR_KEYS,
|
|
'payload',
|
|
)
|
|
const provider = value['provider']
|
|
if (typeof provider !== 'string') {
|
|
throw new Error('persisted subagent descriptor provider must be a string')
|
|
}
|
|
if (mode === 'one-shot') {
|
|
const label = optionalString(value, 'label')
|
|
return {
|
|
version: SUBAGENT_DESCRIPTOR_VERSION,
|
|
mode,
|
|
provider,
|
|
...label !== undefined ? { label } : {},
|
|
}
|
|
}
|
|
const label = value['label']
|
|
if (typeof label !== 'string') {
|
|
throw new Error('persisted subagent descriptor label 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,
|
|
mode,
|
|
provider,
|
|
label,
|
|
...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: OneShotSubagentDescriptorInput,
|
|
): OneShotSubagentDescriptorData
|
|
/**
|
|
* Validate and detach a continuable descriptor input.
|
|
* @param input - the caller-collected continuable composition fields.
|
|
* @returns the versioned, detached continuable descriptor payload.
|
|
* @throws when a field is not losslessly JSON-serializable.
|
|
*/
|
|
export function snapshotSubagentDescriptor(
|
|
input: ContinuableSubagentDescriptorInput,
|
|
): ContinuableSubagentDescriptorData
|
|
export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData {
|
|
const candidate: SubagentDescriptorData = input.mode === 'one-shot'
|
|
? {
|
|
version: SUBAGENT_DESCRIPTOR_VERSION,
|
|
mode: input.mode,
|
|
provider: input.provider,
|
|
...input.label !== undefined ? { label: input.label } : {},
|
|
}
|
|
: {
|
|
version: SUBAGENT_DESCRIPTOR_VERSION,
|
|
mode: input.mode,
|
|
provider: input.provider,
|
|
label: input.label,
|
|
...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 cannot be
|
|
* classified 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)
|
|
}
|