Files
deepseek-harness/packages/subagent/subagent-inprocess/src/index.ts
_Kerman ccebba2349 refactor(agent): unify agent-scoped event signatures as payload objects
All agent/* and agent-loop/config-start-failed events take one payload
object carrying the agent subject; waterfall/serial payloads require a
signal and keep next as the final argument. PreStepContext and
RequestFailureContext are unfolded into payloads and retired.
goal/changed follows the same shape so agentEvents keeps its listener
error containment. ReactLoopAgent builds its scope carrier once in the
constructor. Regenerates scope resolvers, tool-cordis api catalog, and
docs catalogs; updates all affected listeners, tests, and the
core-data-structures docs (en + zh).
2026-08-06 12:13:14 +08:00

236 lines
8.3 KiB
TypeScript

/**
* Shared driver for in-process ONE-SHOT subagent providers. The agent factory's
* creation transaction owns unpublished setup and rollback; after publication
* the returned AgentHandle is the one quiescent lifecycle owner held by the
* provider's caller.
*
* Continuable children never come through here: the continuation manager
* composes and drives them directly, so this driver owns exactly one turn with
* one result.
*
* @module @deepseek-ai/dsh-subagent-inprocess
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import {
applyChildComposition,
assertSubagentMaxDepth,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
} from '@deepseek-ai/dsh-subagent'
import type {
ResolvedSubagentStartRequest,
SubagentDescriptorData,
SubagentResult,
SubagentRun,
SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
// to the policy services when composed — the driver consumes both
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import {
attachStructuredRuntime,
type StructuredAttachment,
} from './structured.ts'
export {
STRUCTURED_OUTPUT_TOOL,
STRUCTURED_OUTPUT_INSTRUCTION,
} from './structured.ts'
/** Map a session turn outcome to the subagent seam's terminal vocabulary. */
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
switch (reason?.kind) {
case 'completed':
return 'completed'
case 'max-tokens':
return 'max-tokens'
case 'aborted':
return 'aborted'
case 'error':
case 'interrupted':
default:
return 'error'
}
}
/** Extra inputs the spawn and fork providers supply to the shared driver. */
export interface InProcessRunOptions {
/** Completed-turn seed for fork, or undefined for a fresh spawn. */
readonly seed?: SessionEvent[]
}
/** Error used when cancellation wins before the child publication boundary. */
function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
}
/** Append one one-shot descriptor inside the child's initial turn before its first request. */
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
let appended = false
childCtx.on('agent/pre-step', async ({ agent }, next) => {
const decision = await next()
if (!appended && decision.kind === 'enter') {
appended = true
agent.session.append('subagent/descriptor', descriptor)
}
return decision
})
}
/**
* Establish and drive one in-process one-shot child. Fulfillment means the agent
* is already published in the registry and transfers its turn, cancellation,
* and disposal work through the returned run. Rejection means the agent
* factory's unpublished creation transaction reached quiescence without
* publishing a child. Every start appends its resolved descriptor inside the
* child's initial turn.
* @param request - the trusted typed start request, including its required signal.
* @param options - the optional fork seed.
* @returns a published holder-owned run.
*/
export async function startInProcessRun(
request: ResolvedSubagentStartRequest,
options: InProcessRunOptions,
): Promise<SubagentRun> {
assertSubagentMaxDepth(request.maxDepth)
if (request.signal.aborted) throw prePublicationAbort()
const parent = request.parent
const childDepth = resolveChildDepth(parent, request.maxDepth)
const childId = SessionId(randomUUID())
const seed = options.seed
const activationBoundary = seed?.length ?? 0
// Capture before the first await: a later parent switch belongs to the
// parent's future.
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
// Inherited overrides land on the child's own log, so its effective policy
// is reconstructable from that log alone.
const childSession = (childCtx.agent as Agent).session
if (inheritedMode !== undefined) {
childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
}
if (inheritedPolicy !== undefined) {
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
}
applyChildComposition(childCtx, {
persona: request.persona,
toolFilter: request.toolFilter,
})
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
attachDescriptorAppend(childCtx, request.descriptor)
}
const handle = await parent.ctx.agents.create({
sessionId: childId,
meta: childSessionMeta(parent, childDepth, activationBoundary),
...seed !== undefined ? { seed } : {},
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
signal: request.signal,
setup,
})
return drivePublishedRun(
handle,
request.signal,
request.prompt,
childId,
activationBoundary,
structured,
)
}
/**
* Wrap a published child in the single run lifecycle that owns signal handoff,
* one turn, result settlement, and quiescent disposal.
*/
function drivePublishedRun(
handle: AgentHandle,
signal: AbortSignal,
prompt: ContentBlock[],
childId: SessionId,
boundary: number,
structured: StructuredAttachment | undefined,
): SubagentRun {
const child = handle.agent
const flags = { cancelled: false }
const onAbort = (): void => {
flags.cancelled = true
child.cancel({ kind: 'parent' })
}
signal.addEventListener('abort', onAbort, { once: true })
// Agent creation detaches its creation-only listener before returning. The
// post-registration check closes that handoff without treating an already
// published child as a failed start.
if (signal.aborted) onAbort()
const result: Promise<SubagentResult> = (async () => {
try {
if (!flags.cancelled) {
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
await child.whenIdle()
}
return readResult(
child,
boundary,
flags.cancelled,
structured ? { captured: structured.captured() } : undefined,
)
} finally {
signal.removeEventListener('abort', onAbort)
}
})()
return {
id: childId,
localAgent: child,
result,
async dispose(): Promise<void> {
signal.removeEventListener('abort', onAbort)
flags.cancelled = true
const settlements = await Promise.allSettled([handle.dispose(), result])
const disposal = settlements[0]
// The result channel owns run faults; disposal reports only failure to
// release the published handle after both operations settle.
if (disposal.status === 'rejected') throw disposal.reason
},
}
}
/** Read one settled child's result from events after its activation boundary. */
function readResult(
child: Agent,
boundary: number,
cancelled: boolean,
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(boundary)
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
const lastEnd = findLastMessageTurnEnd(own)
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead.
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded
if (structured !== undefined) {
if (structured.captured !== undefined) {
return { output, structured: structured.captured.value, stopReason }
}
if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
}
return { output, stopReason }
}