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

@@ -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 () => {