fix(agent-loop): route next-step input during admission
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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] : [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user