fix(subagent): preserve follow-up provenance

This commit is contained in:
Dudu-0223
2026-07-24 13:18:35 +08:00
committed by imccyu
parent 1ab3cbf673
commit 189502e4ac
20 changed files with 202 additions and 87 deletions
@@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, errorChain, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent'
import type {
SubagentDescriptorData,
@@ -70,6 +70,14 @@ export interface InProcessRunOptions {
/** Whether one activation must prove its final state durable before success. */
type Durability = 'best-effort' | 'required'
/** Activation-specific inputs to the shared in-process driver. */
interface DriveTurnOptions {
readonly durability: Durability
/** Attribution for a resumed activation's follow-up prompt. */
readonly source?: MessageSource
readonly structured?: StructuredAttachment
}
/** Error used when cancellation wins before the child publication boundary. */
function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
@@ -177,8 +185,10 @@ export async function startInProcessRun(
request.prompt,
childId,
seedLength,
request.continuation === undefined ? 'best-effort' : 'required',
structured,
{
durability: request.continuation === undefined ? 'best-effort' : 'required',
...structured === undefined ? {} : { structured },
},
)
}
@@ -214,7 +224,14 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis
// The result boundary is this activation's own work: everything already in
// the resumed transcript belongs to earlier turns.
const resumePoint = handle.agent.session.events.length
return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint, 'required')
return driveTurn(
handle,
request.signal,
request.prompt,
request.sessionId,
resumePoint,
{ durability: 'required', source: request.source },
)
}
/**
@@ -230,10 +247,10 @@ function driveTurn(
prompt: ContentBlock[],
childId: SessionId,
boundary: number,
durability: Durability,
structured?: StructuredAttachment,
options: DriveTurnOptions,
): SubagentRun | Promise<never> {
const child = handle.agent
const { durability, source, structured } = options
// Agent creation detaches its creation-only abort listener before returning.
// Close the narrow handoff race before installing the live-run listener.
if (signal.aborted) {
@@ -249,7 +266,7 @@ function driveTurn(
const result: Promise<SubagentResult> = (async () => {
try {
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
child.followup(createUserMessage({ content: prompt, source: source ?? { kind: 'user' } }))
await child.whenIdle()
if (durability === 'required') {
try {
@@ -282,31 +299,27 @@ function driveTurn(
flags.cancelled = true
return handle.dispose()
},
steer(content: ContentBlock[]): void {
// Strict live delivery: the synchronous checks and the Agent.steer()
// call share one frame, so delivery joins the observed turn or throws.
// Agent.steer()'s own idle fallback would instead QUEUE the message and
steer(content: ContentBlock[], steeringSource: MessageSource): void {
// Strict live delivery: the synchronous checks and Agent.trySteer() share
// one frame, so delivery joins the observed step or throws. The ordinary
// Agent.steer() idle fallback would instead queue the message and
// start a new, untracked turn after this run's result was read.
if (child.status !== 'running') {
throw new Error(`subagent child "${childId}" is not running; the message was not delivered`)
}
// The status stays `running` through the closed turn's durability flush,
// and the loop DISCARDS terminal-stopped steering drained after turn
// close instead of recording it. Requiring an open turn keeps
// acknowledged delivery honest.
// Status stays `running` through the closed turn's durability flush, when
// ordinary steering would queue a later turn. Requiring an open turn
// keeps this activation's acknowledged delivery honest.
const lastBoundary = child.session.events.findLast(
event => event.type === 'turn/start' || event.type === 'turn/end',
)
if (lastBoundary?.type !== 'turn/start') {
throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`)
}
// Turn settlement only runs between steps: with no step open, the loop
// may be awaiting its continuation/turn-stopping checkpoint, where
// pending steering was already folded and a later arrival would miss
// this turn. A message accepted during an OPEN step is instead
// drained and recorded at that step's settlement checkpoint before any
// terminal decision (cancellation remains the documented shared-outcome
// race).
// Between steps there is no current step whose final drain can own strict
// delivery. A message accepted during an open step is recorded at that
// step's settlement checkpoint before the continuation decision
// (cancellation remains the documented shared-outcome race).
const lastStep = child.session.events.findLast(
event => event.type === 'step/start' || event.type === 'step/end',
)
@@ -324,7 +337,7 @@ function driveTurn(
if (child.trySteer === undefined) {
throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`)
}
if (!child.trySteer(createUserMessage({ content, source: { kind: 'user' } }))) {
if (!child.trySteer(createUserMessage({ content, source: steeringSource }))) {
throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`)
}
},
@@ -130,7 +130,7 @@ describe('in-process structured output', () => {
if (session.header.parentSession === undefined || run === undefined
|| event.type !== 'tool/result' || rejected !== undefined) return
try {
run.steer?.([{ type: 'text', text: 'one more thing' }])
run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' })
} catch (error: unknown) {
rejected = error
}
@@ -262,6 +262,7 @@ describe('startInProcessRun', () => {
await expect(resumeInProcessRun({
sessionId: SessionId('resumed-child'),
prompt: [{ type: 'text', text: 'continue' }],
source: { kind: 'user' },
parent,
signal: controller.signal,
descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' },
@@ -309,6 +310,7 @@ describe('startInProcessRun', () => {
const run = await resumeInProcessRun({
sessionId: childId,
prompt: [{ type: 'text', text: 'continue' }],
source: { kind: 'plugin', plugin: 'test-coordinator' },
parent,
signal: new AbortController().signal,
descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' },
@@ -384,7 +386,7 @@ describe('startInProcessRun', () => {
const run = await startInProcessRun(request(parent), {})
await run.result
// The child is idle after its turn: Agent.steer() would silently QUEUE.
expect(() => { run.steer!([{ type: 'text', text: 'late' }]) })
expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) })
.toThrow(/not running; the message was not delivered/)
const child = ctx.agents.get(run.id)!
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
@@ -410,7 +412,9 @@ describe('startInProcessRun', () => {
}, 5)
})
expect(child.status).toBe('running')
expect(() => { run.steer!([{ type: 'text', text: 'too late for this turn' }]) })
expect(() => {
run.steer!([{ type: 'text', text: 'too late for this turn' }], { kind: 'user' })
})
.toThrow(/between steps; the message was not delivered/)
releaseStop!()
await run.result
@@ -427,10 +431,10 @@ describe('startInProcessRun', () => {
if (session.header.parentSession === undefined || run === undefined) return
if (event.type === 'assistant/chunk' && !seeded) {
seeded = true
run.steer?.([{ type: 'text', text: 'accepted before the drain' }])
run.steer?.([{ type: 'text', text: 'accepted before the drain' }], { kind: 'user' })
} else if (event.type === 'steering/message' && rejected === undefined) {
try {
run.steer?.([{ type: 'text', text: 'after the drain began' }])
run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' })
} catch (error: unknown) {
rejected = error
}
@@ -493,7 +497,9 @@ describe('startInProcessRun', () => {
} as unknown as Agent
const run = await startInProcessRun(request(parent), {})
expect(() => { run.steer!([{ type: 'text', text: 'unsupported strict delivery' }]) })
expect(() => {
run.steer!([{ type: 'text', text: 'unsupported strict delivery' }], { kind: 'user' })
})
.toThrow(/does not support strict steering; the message was not delivered/)
await run.dispose()
await run.result
@@ -520,7 +526,7 @@ describe('startInProcessRun', () => {
}, 5)
})
expect(child.status).toBe('running')
expect(() => { run.steer!([{ type: 'text', text: 'into the void' }]) })
expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) })
.toThrow(/turn has already closed; the message was not delivered/)
releaseFlush!()
await run.result