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

@@ -44,7 +44,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
acceptsNextStep: true,
ctx: new Context(),
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},

View File

@@ -100,7 +100,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
acceptsNextStep: true,
ctx: new Context(),
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
updateInbox: () => 'not-found',
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })

View File

@@ -179,7 +179,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
status: 'idle',
acceptsNextStep: false,
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},

View File

@@ -889,8 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */',
},
{
signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult',
jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */',
signature: 'async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise<SendMessageResult>',
jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. Rejection means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */',
},
],
},
@@ -1583,7 +1583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n trySteer?(message: UserMessage): boolean;\n inject(message: UserMessage): void;\n}',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n}',
},
{
name: 'AgentCancelCause',
@@ -2669,6 +2669,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SpillSource',
declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}',
},
{
name: 'SteeringOutcome',
declaration: 'export type SteeringOutcome = {\n readonly status: \'admitted\';\n readonly turn: number;\n readonly step: number;\n} | {\n readonly status: \'rejected\';\n};',
},
{
name: 'SteeringReceipt',
declaration: 'export interface SteeringReceipt {\n readonly outcome: Promise<SteeringOutcome>;\n}',
},
{
name: 'StorageForms',
declaration: 'export interface StorageForms {\n}',
@@ -2703,7 +2711,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentRun',
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n steer?(content: ContentBlock[], source: MessageSource): void;\n}',
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n steer?(content: ContentBlock[], source: MessageSource): Promise<void>;\n}',
},
{
name: 'SubagentStartRequest',

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/core/agent-loop/README.md
README.md: 79d2865073c89bd88a4d39fafacb5cf60f1fc10c
README.zh.md: 48c4f4900d25f524942abf53c1bc887e7d125cb3
README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6
README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817

View File

@@ -57,6 +57,8 @@ The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are pa
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it.
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
### Loop lifecycle (`agent.ts`)

View File

@@ -57,6 +57,8 @@ interface Config {
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose资源释放或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected``cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交包括被中断批次中已最终确认的上下文steering 则保持待准入,直到请求接纳它。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`
### 循环生命周期(`agent.ts`

View File

@@ -29,6 +29,8 @@ import type {
RequestError,
RequestErrorAction,
SendOptions,
SteeringOutcome,
SteeringReceipt,
} from '@deepseek-ai/dsh-agent'
import {
BlockAssembler,
@@ -56,6 +58,26 @@ type StepOutcome =
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
/** Internal one-shot controller paired with a public steering receipt. */
interface SteeringDelivery {
readonly receipt: SteeringReceipt
settle(outcome: SteeringOutcome): void
}
/** Create one idempotent steering-admission controller. */
function createSteeringDelivery(): SteeringDelivery {
const { promise, resolve } = Promise.withResolvers<SteeringOutcome>()
let settled = false
return {
receipt: { outcome: promise },
settle(outcome): void {
if (settled) return
settled = true
resolve(outcome)
},
}
}
const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
/** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */
const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
@@ -112,9 +134,13 @@ function requestProposal(header: EpochHeader): LlmCallConfig {
*/
export class ReactLoopAgent implements Agent {
/** Prompts awaiting individual turns. */
private queued: { item: InboxItem; wakeup: boolean }[] = []
private queued: { item: InboxItem; wakeup: boolean; delivery?: SteeringDelivery }[] = []
/** Input taken into the session log at step boundaries. */
private outbox: { message: UserMessage; steering: boolean; item?: InboxItem }[] = []
private outbox: { message: UserMessage; steering: boolean; item?: InboxItem; delivery?: SteeringDelivery }[] = []
/** Steering already committed to the log but not yet captured by a request. */
private pendingAdmissions: SteeringDelivery[] = []
/** Whether the active cancellation preserves already committed pending delivery. */
private preservePendingAdmissionsOnAbort = false
/** Whether observers see a running interval; consecutive turns share it. */
private busy = false
@@ -142,8 +168,6 @@ export class ReactLoopAgent implements Agent {
/** Whether the session log is owed a matching turn end event. */
private turnOpen = false
private stepOpen = false
/** Whether {@link trySteer} can still join the current step's final drain. */
private strictSteeringOpen = false
/** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false
@@ -167,6 +191,15 @@ export class ReactLoopAgent implements Agent {
send(
message: UserMessage,
options: SendOptions,
): void {
this.route(message, options)
}
/** Route one accepted message, optionally tracking steering admission. */
private route(
message: UserMessage,
options: SendOptions,
delivery?: SteeringDelivery,
): void {
const { target, wakeup } = options
if (target === 'next-step' && !wakeup) {
@@ -185,9 +218,9 @@ export class ReactLoopAgent implements Agent {
placement,
})
if (placement === 'steering') {
this.outbox.push({ message, steering: true, item })
this.outbox.push({ message, steering: true, item, ...delivery === undefined ? {} : { delivery } })
} else {
this.queued.push({ item, wakeup })
this.queued.push({ item, wakeup, ...delivery === undefined ? {} : { delivery } })
}
// Preserve the routing decision for every send in this synchronous caller
// stack, while installing quiescence ownership before enqueue observers
@@ -218,6 +251,7 @@ export class ReactLoopAgent implements Agent {
}
case 'remove': {
this.queued.splice(queuedIndex, 1)
pending.delivery?.settle({ status: 'rejected' })
emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
return 'applied'
}
@@ -235,22 +269,14 @@ export class ReactLoopAgent implements Agent {
})
}
/** Steer the open turn, falling back to a waking prompt while idle. */
steer(input: UserMessage): void {
this.send(input, {
/** Steer the open turn, falling back to a tracked waking prompt while idle. */
steer(input: UserMessage): SteeringReceipt {
const delivery = createSteeringDelivery()
this.route(input, {
target: 'next-step',
wakeup: true,
})
}
/** Atomically steer only while the current step still owns its final drain. */
trySteer(input: UserMessage): boolean {
if (!this.strictSteeringOpen) return false
this.send(input, {
target: 'next-step',
wakeup: true,
})
return true
}, delivery)
return delivery.receipt
}
/** Append model-facing context without waking the driver. */
@@ -304,11 +330,17 @@ export class ReactLoopAgent implements Agent {
// inboxes clear; listener failures are contained by the dispatcher.
if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
}
if (options.keepInbox && this.abort !== undefined) this.preservePendingAdmissionsOnAbort = true
if (!options.keepInbox) {
const discarded = this.queued.map(item => item.item)
for (const item of this.queued) item.delivery?.settle({ status: 'rejected' })
for (const item of this.outbox) {
if (item.steering && item.item !== undefined) discarded.push(item.item)
if (item.steering && item.item !== undefined) {
item.delivery?.settle({ status: 'rejected' })
discarded.push(item.item)
}
}
this.rejectPendingAdmissions()
// Clear before abort observers run: replacement work belongs to the next turn.
this.queued.length = 0
this.outbox.length = 0
@@ -373,7 +405,8 @@ export class ReactLoopAgent implements Agent {
// The some() guard above proves the queue is non-empty; the non-null
// assertion expresses that invariant.
// oxlint-disable-next-line typescript/no-non-null-assertion
const { item } = this.queued.shift()!
const pending = this.queued.shift()!
const { item, delivery } = pending
const { message } = item
const inheritedOutboxLength = this.outbox.length
@@ -423,6 +456,7 @@ export class ReactLoopAgent implements Agent {
// still owns the slot here and releasing it unconditionally is exact.
this.abort = undefined
if (admitted === undefined) {
delivery?.settle({ status: 'rejected' })
this.acceptsNextStep = false
try {
this.flushRejectedAdmissionContexts()
@@ -440,7 +474,7 @@ export class ReactLoopAgent implements Agent {
this.continueOrIdle()
return
}
await this.run(trigger, admitted, inheritedOutboxLength)
await this.run(trigger, admitted, inheritedOutboxLength, Object.freeze([]), delivery)
})
// Published only after the abort owner and pending done are installed: a
// dequeue listener that cancels or disposes must find live cancellation
@@ -457,6 +491,7 @@ export class ReactLoopAgent implements Agent {
admitted: UserMessage[] = [],
inheritedOutboxLength = 0,
priorFailures: readonly LlmFailure[] = Object.freeze([]),
promptDelivery?: SteeringDelivery,
): Promise<void> {
// Both entries hold the invariant: kick() clears the admission slot before
// awaiting run(), and a retry is entered only after the prior run clears it.
@@ -464,6 +499,7 @@ export class ReactLoopAgent implements Agent {
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
const controller = new AbortController()
this.abort = controller
this.preservePendingAdmissionsOnAbort = false
this.acceptsNextStep = true
const signal = controller.signal
const turn = this.lastTurn + 1
@@ -487,13 +523,12 @@ export class ReactLoopAgent implements Agent {
// Context or steering retained by an earlier rejected admission happened
// before this prompt and must occupy the same order in durable history.
this.drainOutbox(turn, inheritedOutboxLength)
if (promptDelivery !== undefined) this.pendingAdmissions.push(promptDelivery)
for (const input of admitted) {
this.session.append('user/message', input, { surfaceOp: 'append' })
}
signal.throwIfAborted()
this.drainOutbox(turn)
steps: while (true) {
step += 1
const outcome = await this.step(turn, step, signal)
@@ -501,17 +536,19 @@ export class ReactLoopAgent implements Agent {
case 'completed':
requestFailureHistory = Object.freeze([])
if (outcome.maxTokens) reason = { kind: 'max-tokens' }
// A concluding tool result is terminal: steering already in the
// log waits for the next turn's request instead of reopening this
// one, and the agent/turn-stopping drain below is skipped for the same
// reason.
if (outcome.concluded) break steps
// A concluding tool result is terminal: reject steering that did
// not enter a request, while retaining same-boundary context in
// durable history before the turn closes.
if (outcome.concluded) {
this.discardOutboxSteering()
this.drainOutbox(turn)
break steps
}
if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
break
case 'request-failed': {
// step() reports request failures only after step/start commits
// and before its own step/end, so the step is always open here.
this.strictSteeringOpen = false
this.stepOpen = false
this.session.append('step/end', { turn, step })
if (!signal.aborted) {
@@ -542,12 +579,14 @@ export class ReactLoopAgent implements Agent {
}
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
signal.throwIfAborted()
if (!this.drainOutbox(turn)) break
this.drainOutboxContexts()
if (!this.outbox.some(item => item.steering)) {
break
}
}
} catch (caught: unknown) {
try {
if (this.stepOpen) {
this.strictSteeringOpen = false
this.stepOpen = false
this.session.append('step/end', { turn, step })
}
@@ -565,7 +604,6 @@ export class ReactLoopAgent implements Agent {
// failure paths (step(), the request-failed branch, the catch), so the
// finally owes only the turn boundary.
this.acceptsNextStep = false
this.strictSteeringOpen = false
try {
if (this.turnOpen) {
// Re-entrant turn/end listeners must route new input to a later turn.
@@ -582,6 +620,10 @@ export class ReactLoopAgent implements Agent {
// is still this run's controller here.
this.abort = undefined
signal.removeEventListener('abort', cancelRetry)
const preservePending = signal.aborted && this.preservePendingAdmissionsOnAbort
this.preservePendingAdmissionsOnAbort = false
// oxlint-disable-next-line typescript/no-unnecessary-condition -- keepInbox cancellation can set this while turn work is awaited.
if (!preservePending) this.rejectPendingAdmissions()
}
if (opened) {
@@ -620,10 +662,6 @@ export class ReactLoopAgent implements Agent {
await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
signal.throwIfAborted()
// Take the outbox whole — same-boundary steering and context leave in
// this request together.
this.drainOutbox(turn)
// Assemble request-owned prompt inputs fresh each step. Dynamic context is
// committed at the tail before deriving history once, preserving the stable
// system/history cache prefix while keeping every model-visible byte logged.
@@ -632,13 +670,18 @@ export class ReactLoopAgent implements Agent {
const system = renderPrompt(assembly)
materializeRuntimeContext(session, renderContextSnapshot(assembly))
// Commit the exact pending batch only after every asynchronous
// pre-request contribution succeeded. Input accepted after this splice
// remains pending for a later request.
this.drainOutbox(turn)
// Snapshot the exact log prefix: the reconstruction boundary. Appends
// after this synchronous snapshot join the next request.
const boundaryMessages = session.deriveMessages()
session.append('step/start', { turn, step })
this.stepOpen = true
this.strictSteeringOpen = true
this.admitPendingAdmissions(turn, step)
signal.throwIfAborted()
const { request, preparedCall } = await this.buildRequest(
@@ -705,15 +748,14 @@ export class ReactLoopAgent implements Agent {
))
}
// Tool results stay adjacent to their calls; input accepted during the
// request enters the log only after the complete result batch.
this.strictSteeringOpen = false
const steered = this.drainOutbox(turn)
// Ordinary context keeps the base loop's result-adjacent commit point.
// Steering remains provisional until the next request snapshot admits it.
this.drainOutboxContexts()
session.append('step/end', { turn, step })
this.stepOpen = false
return {
kind: 'completed',
continueTurn: (toolCalls.length > 0 && !concluded) || steered,
continueTurn: (toolCalls.length > 0 && !concluded) || this.outbox.some(item => item.steering),
concluded,
maxTokens: finish.kind === 'max-tokens',
}
@@ -818,25 +860,83 @@ export class ReactLoopAgent implements Agent {
return { request, ...preparedCall === undefined ? {} : { preparedCall } }
}
/** Commit the outbox and report whether it contained steering. */
private drainOutbox(turn: number, limit = this.outbox.length): boolean {
let steered = false
for (const item of this.outbox.splice(0, limit)) {
if (item.steering) {
steered = true
/* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item)
this.session.append(
'steering/message',
{ turn, message: item.message },
{ surfaceOp: 'append' },
)
} else {
this.session.append('user/message', item.message, { surfaceOp: 'append' })
/** Commit one stable outbox prefix and retain tracked delivery until snapshot admission. */
private drainOutbox(turn: number, limit = this.outbox.length): void {
const batch = this.outbox.splice(0, limit)
for (let index = 0; index < batch.length; index += 1) {
const item = batch[index]
/* v8 ignore next -- the index walks the exact array length. */
if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during drain`)
try {
if (item.steering) {
/* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item)
this.session.append(
'steering/message',
{ turn, message: item.message },
{ surfaceOp: 'append' },
)
if (item.delivery !== undefined) this.pendingAdmissions.push(item.delivery)
} else {
this.session.append('user/message', item.message, { surfaceOp: 'append' })
}
} catch (error: unknown) {
item.delivery?.settle({ status: 'rejected' })
this.outbox.unshift(...batch.slice(item.steering ? index + 1 : index))
throw error
}
}
return steered
}
/** Commit ordinary context while retaining provisional steering in order. */
private drainOutboxContexts(): void {
const pending = this.outbox
this.outbox = []
for (let index = 0; index < pending.length; index += 1) {
const item = pending[index]
/* v8 ignore next -- the index walks the exact array length. */
if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during context drain`)
if (item.steering) {
this.outbox.push(item)
continue
}
try {
this.session.append('user/message', item.message, { surfaceOp: 'append' })
} catch (error: unknown) {
this.outbox.push(...pending.slice(index))
throw error
}
}
}
/** Settle every committed steering item captured by this immutable request. */
private admitPendingAdmissions(turn: number, step: number): void {
const outcome: SteeringOutcome = { status: 'admitted', turn, step }
for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle(outcome)
}
/** Reject committed steering that left the inbox without reaching a request. */
private rejectPendingAdmissions(): void {
for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle({ status: 'rejected' })
}
/** Discard uncommitted steering while retaining same-boundary injected context. */
private discardOutboxSteering(): void {
const contexts: typeof this.outbox = []
const discarded: InboxItem[] = []
for (const item of this.outbox) {
if (!item.steering) {
contexts.push(item)
continue
}
item.delivery?.settle({ status: 'rejected' })
/* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
discarded.push(item.item)
}
this.outbox = contexts
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
}
/**

View File

@@ -50,10 +50,11 @@ describe('Agent', () => {
}])).toBeUndefined()
expect(call('inject', [message('context')])).toBeUndefined()
expect(call('followup', [message('followup')])).toBeUndefined()
expect(call('steer', [message('steering')])).toBeUndefined()
const receipt = agent.steer(message('steering'))
await agent.whenIdle()
expect(adapter.requests).toHaveLength(3)
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 3, step: 1 })
})
it('idle inject() appends context without opening a turn or requesting a flush', async () => {

View File

@@ -721,13 +721,14 @@ describe('agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.tools.register(defineContentToolFixture({
name: 'finalize',
description: '',
parameters: {},
async execute(_args, exec) {
// Steering lands while the concluding tool is still executing.
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
exec.concludeTurn()
return [{ type: 'text', text: 'final' }]
},
@@ -740,9 +741,9 @@ describe('agent loop', () => {
expect(adapter.requests).toHaveLength(1)
const events = agent.session.events.map(event => event.type)
expect(events.filter(type => type === 'turn/end')).toHaveLength(1)
// The steering is durable inside the concluded turn and feeds the NEXT
// turn's request instead of being dropped or re-queued.
expect(events).toContain('steering/message')
if (receipt === undefined) throw new Error('concluding tool did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(events).not.toContain('steering/message')
send(agent, 'follow up')
await waitForIdle(ctx, agent)
@@ -751,7 +752,7 @@ describe('agent loop', () => {
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
expect(texts).toContain('late steering')
expect(texts).not.toContain('late steering')
})
it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => {

View File

@@ -64,8 +64,7 @@ The handle every plugin programs against:
- `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session.
- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`.
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it.
- `agent.trySteer?(input)` — an optional strict-steering capability implemented by the default loop. It atomically submits an identified message only while the current step still owns its final drain, returning `false` without accepting input during admission, between steps, or after that drain begins; cancellation and disposal can still discard accepted steering.
- `agent.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it.
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
- `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement.
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.

View File

@@ -64,7 +64,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
- `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩compaction等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。
- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId``InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`
- `agent.followup(input)``send()``next-turn`wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
- `agent.steer(input)``next-step`wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering以供重试或之后获准的提示词使用而取消或 dispose 可能丢弃它。
- `agent.steer(input)``next-step`wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose资源释放或准入前故障会使其解析为 `rejected``cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。
- `agent.inject(input)``next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。
- `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。
- `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时首个信号生效空闲取消是安全空操作不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`

View File

@@ -58,6 +58,20 @@ export type InboxAction =
/** Result of applying an inbox action at the synchronous ownership boundary. */
export type InboxActionResult = 'applied' | 'not-found'
/** Final admission outcome for one call to {@link Agent.steer}. */
export type SteeringOutcome =
| { readonly status: 'admitted'; readonly turn: number; readonly step: number }
| { readonly status: 'rejected' }
/**
* Message-owned steering admission receipt. The outcome promise always
* resolves: synchronous input validation still throws from {@link Agent.steer},
* while lifecycle policy reports non-admission as `rejected`.
*/
export interface SteeringReceipt {
readonly outcome: Promise<SteeringOutcome>
}
/**
* Options for the unified {@link Agent.send} primitive over the
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
@@ -225,26 +239,18 @@ export interface Agent {
followup(message: UserMessage): void
/**
* Submit steering during prompt admission or an open turn — the
* `next-step`/wakeup preset of {@link send}. It stages for the next steering
* checkpoint before a request or stop decision. If the activity fails before
* that boundary, the remainder stays staged without waking the agent; retry
* or a later prompt takes it. Outside that window steering falls back to a
* woken follow-up turn, while cancellation or disposal may discard pending
* steering.
* Submit steering with a message-owned admission receipt — the
* `next-step`/wakeup preset of {@link send}. During prompt admission or an
* open turn, the message waits in the steering FIFO until a committed step
* snapshots it; outside that window it enters the ordinary queued FIFO. The
* receipt resolves `admitted` only after the message joins that step's
* immutable request history, or `rejected` when terminal policy,
* cancellation, or disposal discards it first. A non-terminal turn close may
* leave it staged for a later admitted prompt without settling the receipt.
* @param message - identified steering content and its producer provenance.
* @returns the receipt for this exact message's eventual admission outcome.
*/
steer(message: UserMessage): void
/**
* Atomically submit steering only while the current step still owns its final
* drain. Returns `false` without accepting the message during admission,
* between steps, or after the final per-step drain has begun. Cancellation or
* disposal may still discard previously accepted steering.
* @param message - identified steering content and its producer provenance.
* @returns whether the message entered the current step.
*/
trySteer?(message: UserMessage): boolean
steer(message: UserMessage): SteeringReceipt
/**
* Append model-facing context without running the model — the

View File

@@ -26,7 +26,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
reserveTurnAdmission: () => undefined,
cancel() {},

View File

@@ -36,7 +36,7 @@ function agent(ctx: Context, cwd: string): Agent {
acceptsNextStep: false,
ctx: scope.ctx,
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',

View File

@@ -40,7 +40,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) { appendInjection(session, input) },
reserveTurnAdmission: () => undefined,
cancel() { status = 'idle' },

View File

@@ -50,7 +50,7 @@ function stubAgentForSession(session: Session): StubAgent {
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
if (shouldDefer) deferred.push(input)
else appendInjection(session, input)

View File

@@ -41,7 +41,7 @@ function liveAgent(ctx: Context, session: Session): Agent {
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input: UserMessage) {
session.append('user/message', input, { surfaceOp: 'append' })
},

View File

@@ -35,7 +35,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},

View File

@@ -48,7 +48,7 @@ function stubAgent(session: Session): Agent {
acceptsNextStep: false,
ctx: new Context(),
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',

View File

@@ -45,7 +45,7 @@ function agent(ctx: Context, cwd?: string): Agent {
options: {},
session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }),
status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
@@ -258,7 +258,7 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
@@ -301,7 +301,7 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<undefined>()

View File

@@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
return {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
}

View File

@@ -29,7 +29,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
acceptsNextStep: false,
ctx: scopeFiber.ctx,
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',

View File

@@ -46,7 +46,7 @@ function agent(ctx: Context, cwd: string): Agent {
acceptsNextStep: false,
ctx: scope.ctx,
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',

View File

@@ -42,7 +42,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
acceptsNextStep: false,
ctx: scope.ctx,
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',

View File

@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
const id = SessionId('pty-loader-agent')
const value: Agent = {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value

View File

@@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const agent: Agent = {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent

View File

@@ -49,7 +49,7 @@ function agentForCwd(cwd: string): Agent {
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
@@ -70,7 +70,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},

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) }

View File

@@ -26,7 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
acceptsNextStep: false,
ctx: scopeFiber.ctx,
followup: () => {},
steer: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
send: () => {},
updateInbox: (): 'not-found' => 'not-found',

View File

@@ -228,7 +228,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
steeredOptions.push(input)
const id = input.id
steeredIds.push(id)
return id
return { outcome: Promise.resolve({ status: 'admitted' as const, turn: 1, step: 1 }) }
},
inject(input) {
injected.push(input.content)

View File

@@ -5528,7 +5528,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
@@ -5553,7 +5553,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -5588,14 +5588,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session'))
const agent = {
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -5626,7 +5626,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -5670,7 +5670,7 @@ describe('terminal mounting', () => {
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }