fix(agent-loop): route next-step input during admission

This commit is contained in:
_Kerman
2026-07-27 17:55:55 +08:00
parent 52174e32cb
commit a59ce0367c
18 changed files with 207 additions and 85 deletions

View File

@@ -17,6 +17,7 @@ import type {
Agent,
CancelOptions,
AgentInterruptReason,
InboxPlacement,
AgentOptions,
AgentStatus,
SettleReason,
@@ -51,6 +52,8 @@ export class ReactLoopAgent implements Agent {
/** Whether observers see a running interval; consecutive turns share it. */
private busy = false
/** Whether next-step input belongs to the current admission or open turn. */
private acceptingNextStep = false
/** Abort owner for the current admission or turn. */
private abort: AbortController | undefined
/** Coalesced retry capability scoped to the active request-error waterfall. */
@@ -96,7 +99,7 @@ export class ReactLoopAgent implements Agent {
const { target, wakeup } = options
const id = AgentMessageId(randomUUID())
if (target === 'next-step' && !wakeup) {
if (this.turnOpen) {
if (this.acceptingNextStep) {
this.outbox.push({ content, source })
return id
}
@@ -104,19 +107,19 @@ export class ReactLoopAgent implements Agent {
return id
}
const steering = target === 'next-step' && this.turnOpen
const placement: InboxPlacement = target === 'next-step' && this.acceptingNextStep ? 'steering' : 'queued'
const message: AgentMessage = {
id,
content,
source,
}
if (steering) {
if (placement === 'steering') {
this.outbox.push(message)
} else {
this.queued.push({ message, wakeup })
}
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message)
if (!steering && wakeup) this.kick()
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement)
if (placement === 'queued' && wakeup) this.kick()
return id
}
@@ -212,6 +215,7 @@ export class ReactLoopAgent implements Agent {
const admission = new AbortController()
this.abort = admission
this.acceptingNextStep = true
// Claimed admission is part of the running interval: it is cancellable
// activity, so observers (and their cancel routing) must see it.
if (!this.busy) {
@@ -253,6 +257,7 @@ export class ReactLoopAgent implements Agent {
// still owns the slot here and releasing it unconditionally is exact.
this.abort = undefined
if (admitted === undefined) {
this.acceptingNextStep = false
// A synchronously aborted admission would otherwise publish idle
// inside send()'s own synchronous extent, before any post-send
// subscriber could observe the transition.
@@ -279,6 +284,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.acceptingNextStep = true
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
@@ -384,6 +390,7 @@ export class ReactLoopAgent implements Agent {
// Every step-close happens before this point on both success and
// failure paths (step(), the request-failed branch, the catch), so the
// finally owes only the turn boundary.
this.acceptingNextStep = false
try {
if (this.turnOpen) {
// Re-entrant turn/end listeners must route new input to a later turn.

View File

@@ -4,7 +4,7 @@ import LlmService, { CallId, MessageSource, ProviderRequestId, StreamChunk } fro
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type InboxPlacement } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { ReactLoopAgent } from '../src/agent.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -500,9 +500,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const queuedSources: MessageSource[] = []
const queuedShapes: string[][] = []
ctx.on('agent/inbox/enqueue', (_agent, message) => {
const placements: InboxPlacement[] = []
ctx.on('agent/inbox/enqueue', (_agent, message, placement) => {
queuedSources.push(message.source)
queuedShapes.push(Object.keys(message).sort())
placements.push(placement)
})
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
@@ -516,6 +518,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
['content', 'id', 'source'],
['content', 'id', 'source'],
])
expect(placements).toEqual(['queued', 'steering'])
// The drain appends the durable steering/message with the caller's source
// intact — the log, not a transient emit, is where consumers read it.
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])

View File

@@ -4,7 +4,7 @@ import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type InboxPlacement, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -164,6 +164,95 @@ describe('agent/prompt-submit', () => {
expect(reasons).toEqual([])
})
it('stages inject and steer during admission for the admitted turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const placements: InboxPlacement[] = []
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
ctx.on('agent/inbox/enqueue', (subject, _message, placement) => {
if (subject === agent) placements.push(placement)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'admitted prompt')
await entered.promise
expect(agent.status).toBe('running')
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
agent.inject({
content: [{ type: 'text', text: 'attached context' }],
source: { kind: 'plugin', plugin: 'test' },
})
agent.steer({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } })
expect(events(agent).some(event => event.type === 'user/message')).toBe(false)
expect(placements).toEqual(['queued', 'steering'])
decision.resolve({ kind: 'allow' })
await idle
const staged = events(agent).filter(event =>
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
expect(staged.map(event => event.type)).toEqual([
'turn/start',
'user/message',
'user/message',
'steering/message',
])
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
.toEqual([{ type: 'text', text: 'admitted prompt' }])
expect(staged[2]?.type === 'user/message' && staged[2].data.content)
.toEqual([{ type: 'text', text: 'attached context' }])
expect(staged[3]?.type === 'steering/message' && staged[3].data.content)
.toEqual([{ type: 'text', text: 'admission steering' }])
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('admitted prompt')
expect(request).toContain('attached context')
expect(request).toContain('admission steering')
})
it('keeps admission-time outbox input staged when admission is blocked', async () => {
const adapter = new MockAdapter([textResponse('retried')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const blockedIdle = waitForIdle(ctx, agent)
send(agent, 'blocked prompt')
await entered.promise
agent.inject({
content: [{ type: 'text', text: 'staged context' }],
source: { kind: 'plugin', plugin: 'test' },
})
agent.steer({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } })
decision.resolve({ kind: 'block', reason: 'policy' })
await blockedIdle
expect(events(agent)).toEqual([])
expect(adapter.requests).toEqual([])
const retryIdle = waitForIdle(ctx, agent)
agent.retry()
await retryIdle
const staged = events(agent).filter(event =>
event.type === 'user/message' || event.type === 'steering/message')
expect(staged.map(event => event.type)).toEqual(['user/message', 'steering/message'])
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged context')
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
})
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)

View File

@@ -29,11 +29,15 @@ export interface AgentOptions {
/**
* Which inbox queue a {@link Agent.send} item joins:
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
* - `next-step` — the item joins the active turn between steps as steering,
* or, when no turn is active, is promoted per its `wakeup` flag.
* - `next-step` — during prompt admission or an open turn, the item stages for
* the next safe step boundary; otherwise it is promoted per its `wakeup`
* flag.
*/
export type SendTarget = 'next-turn' | 'next-step'
/** Resolved inbox placement reported when an accepted message is enqueued. */
export type InboxPlacement = 'queued' | 'steering'
/**
* Options for the unified {@link Agent.send} primitive over the
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
@@ -153,12 +157,13 @@ export interface Agent {
* - `next-turn` queues an item that becomes the sole ordinary message of its
* own FIFO-ordered turn; `wakeup:true` wakes a
* parked driver, while `wakeup:false` queues without waking.
* - `next-step` with `wakeup:true` submits steering into the active turn
* (idle falls back to a woken `next-turn`).
* - `next-step` with `wakeup:true` stages steering during prompt admission
* or an open turn; outside that window it falls back to a woken
* `next-turn`.
* - `next-step` with `wakeup:false` injects durable model-facing context
* without running the model: an open turn stages it for the next safe log
* position, while an idle injection appends it immediately without opening
* a turn.
* without running the model: admission or an open turn stages it for the
* next safe log position, while an injection outside that window appends
* immediately without opening a turn.
* @param input - model-facing content and its producer provenance.
* @param options - target queue and wakeup decision.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
@@ -189,12 +194,13 @@ export interface Agent {
followup(input: UserMessageData): AgentMessageId
/**
* Submit steering into the running turn — the `next-step`/wakeup preset of
* {@link send}. An open turn records it at the next steering checkpoint before
* a request or stop decision. If the turn fails before that boundary, the
* remainder stays staged without waking the agent; retry or a later prompt
* takes it. Idle steering falls back to a woken follow-up turn, while
* cancellation or disposal may discard pending steering.
* 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.
* @param input - steering content and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
@@ -202,9 +208,9 @@ export interface Agent {
/**
* Append model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection stages
* at the next safe log position; an idle injection appends immediately
* without opening a turn.
* `next-step`/no-wakeup preset of {@link send}. Admission or an open turn
* stages it at the next safe log position; outside that window it appends
* immediately without opening a turn.
* @param input - injected context and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
@@ -253,13 +259,16 @@ declare module 'cordis' {
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
/**
* An item entered the queued or steering inbox.
* An item entered the queued or steering inbox. `placement` is the
* acceptance-time routing result; listeners must not reconstruct it from
* later agent or session state.
* @param agent - the owning agent.
* @param message - accepted content, source, and correlation identity.
* @param placement - resolved queued or steering placement.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage, placement: InboxPlacement): void
/**
* The driver claimed one item out of the inbox: a queued item at a turn
* boundary, or steering drained between steps. Fires after the item leaves

View File

@@ -52,8 +52,8 @@ describe('agent inbox invariants', () => {
const agent = mockAgent('i1')
const at = scopeTarget(agent, agent)
expect(() => {
ctx.emit(at, 'agent/inbox/enqueue', agent, info())
ctx.emit(at, 'agent/inbox/enqueue', agent, info())
ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued')
ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'steering')
ctx.emit(at, 'agent/inbox/dequeue', agent, info())
ctx.emit(at, 'agent/inbox/discard', agent, [info()])
}).not.toThrow()
@@ -70,7 +70,7 @@ describe('agent inbox invariants', () => {
const ctx = await setup()
const agent = mockAgent('i3')
const at = scopeTarget(agent, agent)
ctx.emit(at, 'agent/inbox/enqueue', agent, info())
ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued')
expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(), info()]) })
.toThrow(/dropped 2 items but only 1 were outstanding/)
})

View File

@@ -41,7 +41,7 @@ describe('scoped-dispatch invariants', () => {
'agent/created': [agent],
'agent/disposed': [agent],
'agent/status': [agent, 'idle'],
'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' } }],
'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' } }, 'queued'],
'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' } }],
'agent/inbox/discard': [agent, []],
'agent/cancel-requested': [agent, { kind: 'user' }],