Files
deepseek-harness/packages/subagent/subagent-inprocess/src/index.ts
T
Yichen Jiang e53f448650 fix(subagent): compose children from their parent's preset
Tool and prompt-section visibility is inherited along dsh-scope's parent
chain, and an agent's scope key is minted with no parent. Per-session agent
presets moved every model-facing row onto the agent plane and made
AgentPresets.mount() the one thing that binds that link, from the api-proxy's
session create, resume, and fork paths. The two in-process subagent drivers
installed only the per-child persona and tool filter, so a child's scope chain
had length one and its registry view resolved the global layer alone — which
is empty wherever a preset roster is composed. One-shot children reached the
model with no tools, continuable ones with only the host-plane `report`, and
neither carried its parent's persona, workspace context, or skill catalog.

AgentPresets.composeFrom() joins one agent to the standing composition another
already runs on. It is a bind, not a mount: the child gets its parent's exact
generation, so a composition edited since the parent started cannot fork it
onto another one, and it is synchronous, which is what lets a child creation
window use it. applyChildComposition() now takes the parent and performs the
join first, making a child composed without it unrepresentable at the call
sites. childSessionMeta() records the joined id so a cold read rebuilds the
composition the child actually ran under.

The audit that followed found two api-proxy readers on the wrong authority:
presenterScopeFor() and the live-agent branch of assertPresetUnchanged() both
read header.agentPreset, which goes stale the moment a blank session switches
preset. A switched session's cold transcript resolved presenters in the older
composition's layer and silently degraded to generic cards, and the gateway
refused to adopt a live session under the preset it actually runs while
accepting the one it left. Both now resolve through resolveSessionPreset(),
matching the resume branch fifteen lines above. The owning architecture Agent
Note carried the stale claim that the header records what a session runs; it
is corrected to name the header/log pair and its three readers.

Fixes #2165
2026-08-10 17:46:34 +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, parent, {
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 }
}