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

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() {},