Files
deepseek-harness/packages/subagent/subagent/src/lifecycle.ts
T
2026-08-10 22:19:14 +08:00

233 lines
9.3 KiB
TypeScript

/**
* Lifecycle-edge publication for both subagent shapes: the contained emitter,
* the one-shot run observer, and the continuable Activation observer.
*
* The public payload contracts ({@link SubagentRunInfo},
* {@link SubagentRunEndInfo}) live in `./types.ts` with the rest of the seam's
* consumer-facing types; this module owns only the implementation and the
* package-private {@link ActivationObserver} the continuation manager consumes.
* Keeping the internal control interface out of the published surface is
* deliberate: the observer's `start`/`capture`/`settle` ordering is a contract
* between this module and one in-package caller, not something a plugin may
* depend on.
*
* @module @deepseek-ai/dsh-subagent/lifecycle
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { finalAssistantOutput } from './assistant-output.ts'
import { SubagentRunId } from './types.ts'
import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
/**
* Lifecycle observer for one Activation's residency epoch, so continuable
* children emit the same start/end pair as one-shot runs. Package-private: the
* continuation manager is the only consumer, and its call ordering is an
* in-package contract rather than a published extension point.
*/
export interface ActivationObserver {
/**
* Publish the start edge once the epoch is resident.
* @param child - the resident child agent, whose log suffix bounds this epoch.
*/
start(child: Agent): void
/**
* Snapshot the child-dependent terminal facts while the child is still
* registered, because handle disposal unregisters it and consumers resolve it
* to read the child's own log and scope.
* @param child - the quiescent child agent about to be released.
*/
capture(child: Agent): void
/**
* Publish the terminal edge exactly once, pairing this epoch's {@link start},
* after the disposal outcome is known. Called only for a resident epoch: a
* failure before residency publishes no edge, because inventing one would
* report a lifecycle the child never had.
* @param failure - the teardown or durability failure, or `undefined` on success.
*/
settle(failure: unknown): void
}
/**
* Publish one lifecycle edge with per-listener exception containment. Run edges
* carry the delegating parent that keys scoped dispatch; provider removal has no
* parent carrier and reaches listeners unscoped.
*
* The service owns this closure because scoped dispatch keys its carrier by the
* exact service instance, whose own context filter composes into the carrier;
* a narrowed stand-in would silently change scope filtering.
*/
export type LifecycleEmitter = {
(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
(name: 'subagent/provider-removed', info: string): void
}
/**
* Build the contained lifecycle emitter this seam publishes every edge through.
* Every listener is independently contained: a synchronous throw or a rejected
* returned promise is logged without starving peer listeners, changing the run,
* or — for provider removal, which fires from a disposer — breaking teardown.
* @param ctx - the service's own context, owning dispatch and the logger.
* @param carrier - resolve the scoped dispatch carrier for one delegating parent.
* @returns the emitter both observers and the provider registry publish through.
*/
export function createLifecycleEmitter(
ctx: Context,
carrier: (parent: Agent) => object,
): LifecycleEmitter {
return (
name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
info: SubagentRunInfo | SubagentRunEndInfo | string,
parent?: Agent,
): void => {
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [carrier(parent), name, info]
for (const callback of ctx.events.dispatch('emit', dispatchArgs)) {
try {
const returned: unknown = callback(info)
void Promise.resolve(returned).catch((error: unknown) => {
ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
}
}
/**
* Emit the start/end lifecycle pair for one accepted one-shot run.
* @param emit - the contained lifecycle emitter.
* @param provider - the provider that established the run.
* @param parent - the delegating parent keying scoped dispatch.
* @param run - the published run whose settlement closes the pair.
* @returns the same run, unchanged.
*/
export function observeRun(
emit: LifecycleEmitter,
provider: string,
parent: Agent,
run: SubagentRun,
): SubagentRun {
const identity = {
runId: SubagentRunId(randomUUID()),
provider,
id: run.id,
local: run.localAgent !== undefined,
}
// Attach the terminal observer before dispatching start. Promise reactions
// still run after this synchronous start emission, preserving start → end.
void run.result.then(
(result) => {
emit('subagent/end', {
...identity,
stopReason: result.stopReason,
// Omit the field when no output exists, matching continuable epochs.
...result.output.length === 0 ? {} : { lastAssistantMessage: result.output },
}, parent)
},
() => {
emit('subagent/end', { ...identity, stopReason: 'error' }, parent)
},
)
emit('subagent/start', identity, parent)
return run
}
/**
* Build the observer for one continuable Activation's residency epoch. Observers
* see the same vocabulary as a one-shot run, so a child's start and settlement
* remain observable without exposing whether the manager materialized, woke, or
* cold-resumed it. Creation failure before residency emits no lifecycle edge.
* @param emit - the contained lifecycle emitter.
* @param provider - the provider name recorded in the durable descriptor.
* @param childId - the durable child session id.
* @param parent - the exact live direct parent keying scoped dispatch.
* @returns the observer whose edges this epoch publishes.
*/
export function createActivationObserver(
emit: LifecycleEmitter,
provider: string,
childId: SessionId,
parent: Agent,
): ActivationObserver {
const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true }
// A cold resume replays earlier turns, so this epoch's telemetry must come
// from the suffix it actually produced — never the whole session, which
// would report a previous epoch's answer when this one opened no turn.
let boundary = 0
// Assigned by `capture()`, which the disposal path always runs before
// `settle()`; a resident epoch therefore always has its facts by then.
let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = {
stopReason: 'completed',
}
return {
start: (child: Agent): void => {
boundary = child.session.events.length
emit('subagent/start', identity, parent)
},
capture: (child: Agent): void => {
const own = child.session.events.slice(boundary)
const output = finalAssistantOutput(own)
captured = {
stopReason: epochStopReason(own),
...output === undefined ? {} : { output },
}
},
settle: (failure: unknown): void => {
const output = failure === undefined ? captured.output : undefined
emit('subagent/end', {
...identity,
stopReason: failure === undefined ? captured.stopReason : 'error',
...output === undefined ? {} : { lastAssistantMessage: output },
}, parent)
},
}
}
/**
* Why this child's last ordinary turn ended, for the terminal lifecycle edge.
* The child's own `turn/end` is authoritative: teardown succeeding says nothing
* about whether the model errored, hit its token ceiling, or was cancelled, so
* deriving the reason from disposal would report failed work as completed.
* @param events - this epoch's own event suffix.
* @returns its terminal stop reason; `completed` when no ordinary turn closed.
*/
function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] {
const reason = findLastMessageTurnEnd(events)?.data.reason
// No ordinary turn closed, so nothing failed either.
if (reason === undefined) return 'completed'
switch (reason.kind) {
case 'max-tokens':
return 'max-tokens'
case 'aborted':
case 'interrupted':
return 'aborted'
case 'error':
return 'error'
case 'completed':
return 'completed'
/* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a
* backend that adds a variant; treating an unnameable reason as success would
* report failed work as completed. */
default:
return 'error'
}
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}