fix(subagent): confirm steering request admission
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 输入
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 不可以),不表示是否继承工具、服务或权限。
|
||||
|
||||
## 所有权与生命周期
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user