fix(subagent): confirm steering request admission

This commit is contained in:
Dudu-0223
2026-07-24 14:32:19 +08:00
committed by imccyu
parent 189502e4ac
commit e1f7eeeb95
58 changed files with 565 additions and 480 deletions

View File

@@ -6,7 +6,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches
A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output.
`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.
`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's confirmed `steer` capability and returns the existing Task id (`steered`) only after a committed request snapshot admits the message; an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Rejection means the message was not delivered: terminal policy or Task settlement winning the admission race never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.
Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface).

View File

@@ -251,7 +251,7 @@ export class SubagentControlService extends Service {
* Deliver one message to a known continuable child: steer its running
* activation, or cold-resume the durable session into a fresh Task-backed
* activation. The two routes are reported distinctly so timing-dependent
* routing is observable. A throw means the message was NOT delivered — in
* routing is observable. Rejection means the message was NOT delivered — in
* particular, losing a race with Task settlement does not fall through to
* cold resume within the same call; a later retry after Task terminal may
* start the next activation. The started Task owns descriptor lookup and
@@ -265,13 +265,18 @@ export class SubagentControlService extends Service {
* @param source - caller-supplied attribution retained across either route.
* @returns whether the message `steered` the existing Task or `started` a new one.
*/
sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult {
async sendMessage(
parent: Agent,
childId: SessionId,
message: ContentBlock[],
source: MessageSource,
): Promise<SendMessageResult> {
this.assertOwnership(childId)
const activation = this.activations.get(childId)
if (activation !== undefined) {
return {
route: 'steered',
taskId: this.steerActivation(activation, parent, childId, message, source),
taskId: await this.steerActivation(activation, parent, childId, message, source),
}
}
return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) }
@@ -301,20 +306,20 @@ export class SubagentControlService extends Service {
}
}
/** Deliver to the running activation's Task through strict live steering. */
private steerActivation(
/** Deliver to the running activation's Task through confirmed live steering. */
private async steerActivation(
activation: ActiveActivation,
parent: Agent,
childId: SessionId,
message: ContentBlock[],
source: MessageSource,
): TaskId {
): Promise<TaskId> {
const taskId = activation.taskId
/* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */
if (taskId === undefined) {
throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
}
// Owner-session authorization plus the live status for the strict check.
// Owner-session authorization plus the live status for admission.
const snapshot = this.ctx.tasks.get(taskId, parent)
if (snapshot.status !== 'running') {
throw new SubagentControlError(
@@ -334,9 +339,9 @@ export class SubagentControlService extends Service {
)
}
try {
run.steer(message, source)
await run.steer(message, source)
} catch (error: unknown) {
// Strict steering lost the race with turn settlement. Deliberately no
// Confirmed steering lost the race with request admission. Deliberately no
// cold-resume fallback here: that would attach the message to a turn the
// caller did not observe.
throw new SubagentControlError(

View File

@@ -17,7 +17,7 @@ import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -30,11 +30,14 @@ interface GatedEntry {
/** Adapter whose entries can hold a model call open until the test releases it. */
class GatedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private script: GatedEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('GatedAdapter: script exhausted')
if (entry.gate) await entry.gate
@@ -216,7 +219,7 @@ describe('SubagentControlService.startContinuable', () => {
expect(snapshot.status).toBe('failed')
expect(snapshot.detail).toContain('maxDepth')
// The unmaterialized child id is reported unavailable on later use.
const followUp = sendMessage(ctx, parent, started.childId, message('hello?'))
const followUp = await sendMessage(ctx, parent, started.childId, message('hello?'))
expect(followUp.route).toBe('started')
const failed = await waitTerminal(ctx, followUp.taskId, parent)
expect(failed.status).toBe('failed')
@@ -264,20 +267,23 @@ describe('SubagentControlService.sendMessage', () => {
await waitPublishedRun(ctx, started.childId)
expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' })
expect(() => sendMessage(ctx, parent, started.childId, message('join')))
.toThrow(/provider does not accept live delivery/)
await expect(sendMessage(ctx, parent, started.childId, message('join')))
.rejects.toThrow(/provider does not accept live delivery/)
let terminalDeliveryError: unknown
let terminalDelivery: Promise<void> | undefined
ctx.tasks.onTaskDone((snapshot) => {
if (snapshot.id !== started.taskId) return
try {
sendMessage(ctx, parent, started.childId, message('after terminal'))
} catch (error: unknown) {
terminalDeliveryError = error
}
terminalDelivery = sendMessage(ctx, parent, started.childId, message('after terminal')).then(
() => undefined,
(error: unknown) => {
terminalDeliveryError = error
},
)
})
result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' })
await waitTerminal(ctx, started.taskId, parent)
await terminalDelivery
expect(String(terminalDeliveryError)).toContain('is completed')
})
@@ -310,8 +316,8 @@ describe('SubagentControlService.sendMessage', () => {
const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local'))
await waitPublishedRun(ctx, started.childId)
expect(() => sendMessage(ctx, parent, started.childId, message('join')))
.toThrow(/registry agent is not the associated activation's agent/)
await expect(sendMessage(ctx, parent, started.childId, message('join')))
.rejects.toThrow(/registry agent is not the associated activation's agent/)
result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' })
await waitTerminal(ctx, started.taskId, parent)
})
@@ -322,30 +328,32 @@ describe('SubagentControlService.sendMessage', () => {
// second step in the SAME turn.
let releaseFirst!: () => void
const gate = new Promise<void>((resolve) => { releaseFirst = resolve })
const { ctx, parent } = await setupWith(new GatedAdapter([
const adapter = new GatedAdapter([
{ chunks: textResponse('first step answer'), gate },
{ chunks: textResponse('steered turn answer') },
]))
])
const { ctx, parent } = await setupWith(adapter)
const started = ctx.subagentControl.startContinuable(startSpec(parent))
// Wait for the child agent to publish and enter running.
// Wait until the first immutable request has crossed the adapter boundary.
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
if (ctx.agents.get(started.childId)?.status === 'running') {
if (adapter.requests.length === 1) {
clearInterval(timer)
resolve()
}
}, 5)
})
const delivered = ctx.subagentControl.sendMessage(
const delivery = ctx.subagentControl.sendMessage(
parent,
started.childId,
message('also consider Y'),
coordinatorSource,
)
expect(delivered).toEqual({ route: 'steered', taskId: started.taskId })
releaseFirst()
const delivered = await delivery
expect(delivered).toEqual({ route: 'steered', taskId: started.taskId })
const snapshot = await waitTerminal(ctx, started.taskId, parent)
expect(snapshot.status).toBe('completed')
// Exactly one Task exists: steering created none.
@@ -360,13 +368,57 @@ describe('SubagentControlService.sendMessage', () => {
expect(steering?.data.message.source).toEqual(coordinatorSource)
})
it('rejects before acknowledgement when terminal policy prevents steering admission', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', 'structured_output', { answer: 7 }),
])
const startedTool = Promise.withResolvers<undefined>()
const releaseTool = Promise.withResolvers<undefined>()
ctx.on('tools/pre-execute', async (exec, next) => {
if (exec.name === 'structured_output') {
startedTool.resolve(undefined)
await releaseTool.promise
}
return next()
})
const base = startSpec(parent)
const started = ctx.subagentControl.startContinuable({
...base,
request: {
...base.request,
outputSchema: {
type: 'object',
properties: { answer: { type: 'number' } },
required: ['answer'],
},
},
})
await startedTool.promise
const delivery = ctx.subagentControl.sendMessage(
parent,
started.childId,
message('follow-up that terminal policy rejects'),
coordinatorSource,
)
releaseTool.resolve(undefined)
await expect(delivery).rejects.toThrow(/message was not delivered/)
const snapshot = await waitTerminal(ctx, started.taskId, parent)
expect(snapshot.status).toBe('completed')
expect(adapter.requests).toHaveLength(1)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false)
})
it('cold-resumes a settled child into a fresh Task and reports `started`', async () => {
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
const started = ctx.subagentControl.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
expect(ctx.agents.get(started.childId)).toBeUndefined()
const followUp = ctx.subagentControl.sendMessage(
const followUp = await ctx.subagentControl.sendMessage(
parent,
started.childId,
message('and then?'),
@@ -409,7 +461,7 @@ describe('SubagentControlService.sendMessage', () => {
expect(descriptor?.data.persona).toBe('You are the resumable child.')
expect(descriptor?.data.toolFilter).toEqual({ deny: [] })
const followUp = sendMessage(ctx, parent, started.childId, message('continue'))
const followUp = await sendMessage(ctx, parent, started.childId, message('continue'))
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
expect(snapshot.status).toBe('completed')
// The resumed child's system prompt carried the persona back.
@@ -438,7 +490,7 @@ describe('SubagentControlService.sendMessage', () => {
parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } }))
await parent.whenIdle()
const followUp = sendMessage(ctx, parent, started.childId, message('follow up'))
const followUp = await sendMessage(ctx, parent, started.childId, message('follow up'))
await waitTerminal(ctx, followUp.taskId, parent)
const resumed = await ctx.sessionPersistence.load(started.childId)
// The persisted seed boundary is unchanged and parent turn two is absent.
@@ -454,7 +506,7 @@ describe('SubagentControlService.sendMessage', () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
const started = ctx.subagentControl.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
const followUp = sendMessage(ctx, parent, started.childId, message('go on'))
const followUp = await sendMessage(ctx, parent, started.childId, message('go on'))
const childAgents: Agent[] = []
const stop = ctx.on('agent/created', (agent: Agent) => {
@@ -474,7 +526,7 @@ describe('SubagentControlService.sendMessage', () => {
const started = ctx.subagentControl.startContinuable(startSpec(otherParent))
await waitTerminal(ctx, started.taskId, otherParent)
const attempt = sendMessage(ctx, parent, started.childId, message('mine now'))
const attempt = await sendMessage(ctx, parent, started.childId, message('mine now'))
expect(attempt.route).toBe('started')
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
expect(snapshot.status).toBe('failed')
@@ -493,7 +545,7 @@ describe('SubagentControlService.sendMessage', () => {
await handle.agent.whenIdle()
await handle.dispose()
const attempt = sendMessage(ctx, parent, SessionId('plain-child'), message('continue?'))
const attempt = await sendMessage(ctx, parent, SessionId('plain-child'), message('continue?'))
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
expect(snapshot.status).toBe('failed')
expect(snapshot.detail).toContain(
@@ -503,9 +555,9 @@ describe('SubagentControlService.sendMessage', () => {
it('derives fallback and bounded labels for resumed activations', async () => {
const { ctx, parent } = await setup([])
const blank = sendMessage(ctx, parent, SessionId('blank-child'), message(' '))
const blank = await sendMessage(ctx, parent, SessionId('blank-child'), message(' '))
const longText = 'x'.repeat(100)
const long = sendMessage(ctx, parent, SessionId('long-child'), message(longText))
const long = await sendMessage(ctx, parent, SessionId('long-child'), message(longText))
expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up')
expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}`)
@@ -523,14 +575,14 @@ describe('SubagentControlService.sendMessage', () => {
meta: { parentSession: parent.id },
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
.toThrow(SubagentControlError)
expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
.toThrow(/outside control-service ownership.*not delivered/)
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
.rejects.toThrow(SubagentControlError)
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
.rejects.toThrow(/outside control-service ownership.*not delivered/)
await handle.dispose()
})
it('does not fall through to cold resume when strict steering loses the settlement race', async () => {
it('does not fall through to cold resume when steering loses the admission race', async () => {
// Deterministic race: hold run disposal open so the association still
// names a run whose child turn has already ended.
const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')])
@@ -564,15 +616,15 @@ describe('SubagentControlService.sendMessage', () => {
}, 5)
})
// Strict steering finds the settled child, fails loud, and does NOT start
// Confirmed steering finds the settled child, fails loud, and does NOT start
// a cold resume within this call.
expect(() => sendMessage(ctx, parent, started.childId, message('too late?')))
.toThrow(/not delivered/)
await expect(sendMessage(ctx, parent, started.childId, message('too late?')))
.rejects.toThrow(/not delivered/)
expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId])
releaseDispose()
await waitTerminal(ctx, started.taskId, parent)
// AFTER the Task settles, retry legitimately starts the next activation.
const retry = sendMessage(ctx, parent, started.childId, message('retry'))
const retry = await sendMessage(ctx, parent, started.childId, message('retry'))
expect(retry.route).toBe('started')
await waitTerminal(ctx, retry.taskId, parent)
})
@@ -581,7 +633,7 @@ describe('SubagentControlService.sendMessage', () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
const started = ctx.subagentControl.startContinuable(startSpec(parent))
await waitTerminal(ctx, started.taskId, parent)
const followUp = sendMessage(ctx, parent, started.childId, message('more'))
const followUp = await sendMessage(ctx, parent, started.childId, message('more'))
const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' })
expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/)
})
@@ -600,7 +652,7 @@ describe('SubagentControlService.sendMessage', () => {
return realLoad(id)
}
const followUp = sendMessage(ctx, parent, started.childId, message('follow up'))
const followUp = await sendMessage(ctx, parent, started.childId, message('follow up'))
expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested')
releaseLoad()
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
@@ -622,12 +674,12 @@ describe('SubagentControlService.sendMessage', () => {
return realLoad(id)
}
const first = sendMessage(ctx, parent, started.childId, message('first follow-up'))
const first = await sendMessage(ctx, parent, started.childId, message('first follow-up'))
expect(first.route).toBe('started')
// The association is installed synchronously, so the competing caller
// observes the pending activation instead of starting a duplicate resume.
expect(() => sendMessage(ctx, parent, started.childId, message('second follow-up')))
.toThrow(/not delivered/)
await expect(sendMessage(ctx, parent, started.childId, message('second follow-up')))
.rejects.toThrow(/not delivered/)
releaseLoad()
const snapshot = await waitTerminal(ctx, first.taskId, parent)
expect(snapshot.status).toBe('completed')

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: eb5d973566f01c05b43f4f56eff746b7af93f60b
README.zh.md: 5be640f9b6da6402ece0e1d15997d9e2970a7d1c
README.md: 6225b84f1274b61cae1d4ca567155dcc6e6a0888
README.zh.md: 5c3ab3baa3ab86f33fe34026ddbdf97449cb4f92

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, strict steering, and disposal—has one implementation here.
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, confirmed steering, and disposal—has one implementation here.
## Start contract
@@ -31,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
Runs expose the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read.
Runs expose confirmed `steer`: a synchronous status check prevents the Agent-level idle fallback from starting an untracked turn, then the run submits through `Agent.steer()` and awaits that exact message's receipt. Fulfillment means a committed child request snapshot admitted the message; terminal turn policy, cancellation, disposal, or a settlement race rejects instead. A synchronously visible structured capture is rejected before submission because its terminal outcome is already authoritative. The run never falls through from rejected live delivery to a later queued turn or cold resume.
## Spawn and fork inputs

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容fork 传入父 agent智能体已完成轮次的前缀。其余机制包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、严格 steering中途引导和 dispose资源释放都在此共用同一套实现。
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容fork 传入父 agent智能体已完成轮次的前缀。其余机制包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、确认式 steering中途引导和 dispose资源释放都在此共用同一套实现。
## 启动契约
@@ -31,7 +31,7 @@
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
运行公开严格的 `steer` 功能:同步检查与 `Agent.trySteer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的步骤,要么抛错。交付要求 `AgentStatus.running`、子 agent 日志中有开放的轮次和步骤、没有已提交的结构化捕获,并且在该步骤的最终 drain 开始前获接纳。提示词接纳、`agent/turn-stopping` 等步骤间处理,以及已关闭轮次的持久性 flush 都会拒绝交付。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback否则会在运行结果读取后启动一个未被跟踪的轮次
运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息结束轮次的策略、取消、dispose资源释放或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝因为其终态结果已经具有权威性。实时投递被拒绝后运行不会转而进入之后的排队轮次或冷恢复
## Spawn 与 fork 输入

View File

@@ -238,8 +238,8 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis
* Drive one activation turn on a published child and wrap it as a run. The
* caller has already created or resumed the agent; this owns the
* signal-handoff race, the live abort listener, result collection past
* `boundary`, the continuable-run durability confirmation, strict steering,
* and disposal.
* `boundary`, the continuable-run durability confirmation, confirmed
* steering, and disposal.
*/
function driveTurn(
handle: AgentHandle,
@@ -299,46 +299,21 @@ function driveTurn(
flags.cancelled = true
return handle.dispose()
},
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.
async steer(content: ContentBlock[], steeringSource: MessageSource): Promise<void> {
// The status check and submission share one synchronous frame. An idle
// Agent.steer() would queue an untracked turn after this run's result.
if (child.status !== 'running') {
throw new Error(`subagent child "${childId}" is not running; the message was not delivered`)
}
// 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`)
}
// 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',
)
if (lastStep?.type !== 'step/start') {
throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`)
}
// A committed structured capture makes the pending step conclusion
// terminal. The capture is synchronously observable, so reject rather
// than acknowledge a message the run is about to drop.
// Avoid waiting for the structured terminal checkpoint when its outcome
// is already authoritative and synchronously visible.
if (structured?.captured() !== undefined) {
throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`)
}
// The atomic Agent operation closes before the final drain, so this
// cannot acknowledge content that the current step will not record.
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: steeringSource }))) {
throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`)
const receipt = child.steer(createUserMessage({ content, source: steeringSource }))
const outcome = await receipt.outcome
if (outcome.status === 'rejected') {
throw new Error(`subagent child "${childId}" stopped before steering admission; the message was not delivered`)
}
},
}

View File

@@ -120,26 +120,24 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('strict steer rejects delivery once the structured result is captured', async () => {
it('confirmed steering rejects delivery once the structured result is captured', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
// oxlint-disable-next-line prefer-const -- single assignment follows listener registration so pre-fulfillment events remain guardable.
let run: Awaited<ReturnType<typeof ctx.subagents.start>> | undefined
let rejected: unknown
let delivery: Promise<void> | undefined
ctx.on('session/event', (session, event) => {
if (session.header.parentSession === undefined || run === undefined
|| event.type !== 'tool/result' || rejected !== undefined) return
try {
run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' })
} catch (error: unknown) {
rejected = error
}
|| event.type !== 'tool/result' || delivery !== undefined) return
delivery = run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' })
void delivery?.catch(() => undefined)
})
run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(rejected).toBeInstanceOf(Error)
expect((rejected as Error).message)
.toMatch(/already reported its structured result; the message was not delivered/)
if (delivery === undefined) throw new Error('structured result did not submit steering')
await expect(delivery)
.rejects.toThrow(/already reported its structured result; the message was not delivered/)
expect(result.structured).toEqual({ answer: 7 })
await run.dispose()
})

View File

@@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent'
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { resumeInProcessRun, startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -290,7 +291,7 @@ describe('startInProcessRun', () => {
reserveTurnAdmission: () => undefined,
updateInbox: () => 'not-found',
followup(): void {},
steer(): void {},
steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } },
inject(): void {},
cancel(): void {},
whenIdle: () => Promise.resolve(),
@@ -381,156 +382,103 @@ describe('startInProcessRun', () => {
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('strict steer rejects a settled child instead of queueing an untracked turn', async () => {
it('confirmed steering rejects a settled child instead of queueing an untracked turn', async () => {
const { ctx, parent } = await setup([textResponse('done')])
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' }], { kind: 'user' }) })
.toThrow(/not running; the message was not delivered/)
await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }))
.rejects.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)
await run.dispose()
})
it('strict steer rejects the between-steps turn-stopping window', async () => {
// Hold `agent/turn-stopping` open after the step closed and pending
// steering was folded into the continuation decision.
const { ctx, parent } = await setup([textResponse('quick')])
let releaseStop: (() => void) | undefined
ctx.on('agent/turn-stopping', (agent) => {
if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined
return new Promise((resolve) => {
releaseStop = () => { resolve(undefined) }
})
})
const run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
if (releaseStop !== undefined) { clearInterval(timer); resolve() }
}, 5)
})
expect(child.status).toBe('running')
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
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
await run.dispose()
})
it('strict steer rejects reentrant delivery after the final drain begins', async () => {
const { ctx, parent } = await setup([textResponse('quick')])
let run: Awaited<ReturnType<typeof startInProcessRun>> | undefined
let seeded = false
let rejected: unknown
ctx.on('session/event', (session, event) => {
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' }], { kind: 'user' })
} else if (event.type === 'steering/message' && rejected === undefined) {
try {
run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' })
} catch (error: unknown) {
rejected = error
}
}
})
run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
await run.result
expect(seeded).toBe(true)
expect(rejected).toBeInstanceOf(Error)
expect((rejected as Error).message)
.toMatch(/passed its steering checkpoint; the message was not delivered/)
expect(child.session.events.filter(event => event.type === 'steering/message')).toHaveLength(1)
await run.dispose()
})
it('strict steer rejects an Agent implementation without atomic steering', async () => {
const childId = SessionId('custom-loop-child')
const childSession = new Session(childId)
childSession.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
childSession.append('step/start', { turn: 1, step: 1 })
const idle = Promise.withResolvers<undefined>()
const child = {
id: childId,
options: {},
session: childSession,
status: 'running',
acceptsNextStep: false,
ctx: new Context(),
send(): void {},
reserveTurnAdmission: () => undefined,
updateInbox: () => 'not-found',
followup(): void {},
steer(): void {},
inject(): void {},
cancel(): void {},
whenIdle: () => idle.promise,
} as Agent
const parentId = SessionId('custom-loop-parent')
const parent = {
id: parentId,
options: {},
session: new Session(parentId),
ctx: {
get: () => undefined,
agents: {
create: () => Promise.resolve({
agent: child,
dispose: () => {
idle.resolve(undefined)
return Promise.resolve()
},
}),
},
it('confirmed steering rejects when a concluding tool prevents request admission', async () => {
const { ctx, parent } = await setup([toolCallResponse('c1', 'finalize', {})])
const enteredTool = Promise.withResolvers<undefined>()
const releaseTool = Promise.withResolvers<undefined>()
ctx.tools.register(defineContentToolFixture({
name: 'finalize',
description: 'Finish the child run.',
parameters: {},
async execute(_args, exec) {
enteredTool.resolve(undefined)
await releaseTool.promise
exec.concludeTurn()
return [{ type: 'text', text: 'final' }]
},
} as unknown as Agent
const run = await startInProcessRun(request(parent), {})
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
})
it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => {
// Hold the turn-end durability flush open: the turn has closed in the log
// and status is still `running`, exactly the window where the loop would
// discard a drained steering message instead of recording it.
const { ctx, parent } = await setup([textResponse('quick')])
let releaseFlush: (() => void) | undefined
ctx.on('session/flush', (session) => {
if (session.header.parentSession === undefined || releaseFlush !== undefined) return
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
if (lastEnd === undefined) return
return new Promise<void>((resolve) => { releaseFlush = resolve })
})
}))
const run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
// Wait until the child's turn has closed while the flush keeps it running.
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
if (releaseFlush !== undefined) { clearInterval(timer); resolve() }
}, 5)
})
expect(child.status).toBe('running')
expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) })
.toThrow(/turn has already closed; the message was not delivered/)
releaseFlush!()
await enteredTool.promise
const delivery = run.steer!([{ type: 'text', text: 'terminal race' }], { kind: 'user' })
releaseTool.resolve(undefined)
await expect(delivery).rejects.toThrow(/stopped before steering admission; the message was not delivered/)
await run.result
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
await run.dispose()
})
it('confirmed steering fulfills only after the next request snapshot admits it', async () => {
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
const enteredStopping = Promise.withResolvers<undefined>()
const releaseStopping = Promise.withResolvers<undefined>()
let held = false
ctx.on('agent/turn-stopping', (agent) => {
if (agent.session.header.parentSession === undefined || held) return
held = true
enteredStopping.resolve(undefined)
return releaseStopping.promise
})
const run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
await enteredStopping.promise
let settled = false
const delivery = run.steer!([{ type: 'text', text: 'after the first step' }], { kind: 'user' })
.then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
releaseStopping.resolve(undefined)
await delivery
const result = await run.result
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('after the first step')
expect((result.output[0] as { text?: string }).text).toBe('second')
const steering = child.session.events.find(event => event.type === 'steering/message')
expect(steering?.type === 'steering/message' && steering.data.message.source).toEqual({ kind: 'user' })
await run.dispose()
})
it('carries steering from a non-terminal flush window into a tracked next turn', async () => {
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
const enteredFlush = Promise.withResolvers<undefined>()
const releaseFlush = Promise.withResolvers<undefined>()
let held = false
ctx.on('session/flush', (session) => {
if (session.header.parentSession === undefined || held) return
if (!session.events.some(event => event.type === 'turn/end')) return
held = true
enteredFlush.resolve(undefined)
return releaseFlush.promise
})
const run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
await enteredFlush.promise
expect(child.status).toBe('running')
const delivery = run.steer!([{ type: 'text', text: 'next tracked turn' }], { kind: 'user' })
releaseFlush.resolve(undefined)
await delivery
const result = await run.result
expect(adapter.requests).toHaveLength(2)
expect(child.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false)
expect((result.output[0] as { text?: string }).text).toBe('second')
await run.dispose()
})
})

View File

@@ -235,7 +235,7 @@ describe('dsh-subagent-spawn', () => {
expect(result.stopReason).toBe('aborted')
})
it('exposes strict steer (no run-level resume): a settled child throws instead of queueing', async () => {
it('exposes confirmed steer (no run-level resume): a settled child rejects instead of queueing', async () => {
const { ctx, parent } = await setup([textResponse('x')])
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
// A run represents one disposable activation: cold resume is a provider
@@ -243,11 +243,11 @@ describe('dsh-subagent-spawn', () => {
expect('resume' in run).toBe(false)
expect(typeof run.steer).toBe('function')
await run.result
// Strict live-only contract: after the child settles, delivery fails loud
// Confirmed live-only contract: after the child settles, delivery fails loud
// rather than falling back to Agent.steer()'s idle queue (which would
// start an untracked turn).
expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) })
.toThrow(/not running; the message was not delivered/)
await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }))
.rejects.toThrow(/not running; the message was not delivered/)
await run.dispose()
})

View File

@@ -45,7 +45,7 @@ Start-time features are advertised in `provider.capabilities` because the servic
- `toolFilter` — apply the requested child tool restriction.
- `persona` — apply a per-child persona.
Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` delivers strictly to the actively running child turn (it throws rather than queueing when the child is not running), and `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart.
Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` fulfills only after a request snapshot in the active child admits the message and rejects rather than queueing an untracked turn, while `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart.
## The durable descriptor

View File

@@ -42,12 +42,12 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
- `toolFilter`:应用请求的子 agent 工具限制;
- `persona`:应用每个子 agent 独立的 persona。
运行时功能通过可选方法是否存在来检查能力:`SubagentRun.steer?` 只有在活跃子 agent 的请求 snapshot 接纳消息后才会兑现,并会拒绝而非排队一个未跟踪轮次;`SubagentProvider.resume?` 则重建已持久化且可继续的子 agent。一次运行表示一个可 dispose资源释放的 activation因此刻意不提供冷恢复操作已释放的运行无法在重启后重建。
## 委派深度
该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth``delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。
运行时功能是 `SubagentRun` 上的可选方法:`sendMessage?` 可对正在运行的子 agent 进行 steering中途引导`resume?` 则异步创建延续运行。方法是否存在就是能力检查。
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。
## 所有权与生命周期

View File

@@ -28,7 +28,7 @@ export function SubagentRunId(id: string): SubagentRunId {
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
* degradation" rule). These static flags cover features needed before a run exists; runtime
* capabilities are optional methods whose presence is the capability — strict live steering
* capabilities are optional methods whose presence is the capability — confirmed live steering
* is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each
* flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to
* `maxDepth`; the other names match.
@@ -221,19 +221,16 @@ export interface SubagentRun {
*/
dispose(): Promise<void>
/**
* OPTIONAL (strict live-steering capability): deliver additional content to
* the actively running child turn. STRICT means delivery joins the observed
* turn or fails — the implementation must synchronously verify, with no
* asynchronous boundary before delivery, that the child is running and its
* turn can still record the message, and must not fall back to a queue path
* that could start a new, untracked turn or silently drop the message after
* this run has settled. Throws when delivery cannot join the turn. A run
* represents one disposable activation, so it has no cold-resume operation;
* resuming a settled child goes through {@link SubagentProvider.resume}.
* `source` is retained on the child's logged steering message without
* changing its user role in model history.
* OPTIONAL (confirmed live-steering capability): submit additional content
* to the active child and fulfill only after a committed request snapshot
* admits it. Rejects when terminal policy, cancellation, disposal, or a lost
* settlement race prevents admission; it never falls through to a queued
* untracked turn or cold resume. A run represents one disposable activation,
* so resuming a settled child goes through {@link SubagentProvider.resume}.
* `source` is retained on the admitted steering message without changing its
* user role in model history.
*/
steer?(content: ContentBlock[], source: MessageSource): void
steer?(content: ContentBlock[], source: MessageSource): Promise<void>
}
/**

View File

@@ -101,7 +101,7 @@ describe('dsh-tool-subagent-control', () => {
// Reach past the tool into the control service to fake a running route
// deterministically: the tool is a thin adapter, so its steered wording is
// what this test pins.
ctx.subagentControl.sendMessage = (agent, _childId, message, messageSource) => {
ctx.subagentControl.sendMessage = async (agent, _childId, message, messageSource) => {
steered = (message[0] as { text: string }).text
source = messageSource
return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }