fix(agent-loop): settle rejected admission context
This commit is contained in:
@@ -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: 95b4410aaaf847dac73e9cca327c304af66aed7f
|
||||
README.zh.md: acd8dd875856bd1df79030e301d3a2174e391bd7
|
||||
README.md: 25fa42ce09a7ad6463e9c52c08bf718d1f55adc1
|
||||
README.zh.md: 0366ce01f8d093dcc142c0ae25cfdfd7b6effbc2
|
||||
|
||||
@@ -54,7 +54,7 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
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. 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 and leaves caller-staged input 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. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
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. 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. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
|
||||
### Loop lifecycle (`agent.ts`)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ interface Config {
|
||||
|
||||
实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
|
||||
|
||||
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文,并会保留调用方暂存的输入,以供重试或之后获准的提示词使用。窗口之外,steering(中途引导)会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
|
||||
### 循环生命周期(`agent.ts`)
|
||||
|
||||
|
||||
@@ -258,6 +258,15 @@ export class ReactLoopAgent implements Agent {
|
||||
this.abort = undefined
|
||||
if (admitted === undefined) {
|
||||
this.acceptsNextStep = false
|
||||
try {
|
||||
this.flushRejectedAdmissionContexts()
|
||||
} catch (error: unknown) {
|
||||
// No turn exists for agent/error coordinates. Preserve the
|
||||
// uncommitted suffix for a later boundary and report locally.
|
||||
this.loopCtx.logger.warn(
|
||||
`agent "${this.id}": committing rejected-admission context failed: ${errorChain(error)}`,
|
||||
)
|
||||
}
|
||||
// A synchronously aborted admission would otherwise publish idle
|
||||
// inside send()'s own synchronous extent, before any post-send
|
||||
// subscriber could observe the transition.
|
||||
@@ -620,6 +629,27 @@ export class ReactLoopAgent implements Agent {
|
||||
return steered
|
||||
}
|
||||
|
||||
/**
|
||||
* Give context-only input its ordinary idle placement when admission
|
||||
* produces no turn. Steering keeps the whole boundary staged so context
|
||||
* accepted beside it cannot split from the request it accompanies.
|
||||
*/
|
||||
private flushRejectedAdmissionContexts(): void {
|
||||
if (this.outbox.some(message => 'id' in message)) return
|
||||
const contexts = this.outbox.splice(0)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
const context = contexts[index]
|
||||
/* v8 ignore next 2 -- the steering precheck proves this batch is context-only */
|
||||
if (context === undefined || 'id' in context) throw new Error('rejected-admission context batch changed')
|
||||
try {
|
||||
this.session.append('user/message', context, { surfaceOp: 'append' })
|
||||
} catch (error: unknown) {
|
||||
this.outbox.unshift(...contexts.slice(index))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single settlement funnel: classify one turn failure (interruption
|
||||
* beats error) into the durable turn/end reason and live settlement report.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
@@ -257,6 +257,65 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
|
||||
})
|
||||
|
||||
it('commits context-only injection when admission closes without a turn', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { 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 idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'blocked prompt')
|
||||
await entered.promise
|
||||
agent.inject({
|
||||
content: [{ type: 'text', text: 'independent context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
decision.resolve({ kind: 'block', reason: 'policy' })
|
||||
await idle
|
||||
|
||||
const log = events(agent)
|
||||
expect(log.map(event => event.type)).toEqual(['user/message'])
|
||||
expect(log[0]?.type === 'user/message' && log[0].data.content)
|
||||
.toEqual([{ type: 'text', text: 'independent context' }])
|
||||
expect(adapter.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('retains rejected-admission context when its idle append fails', async () => {
|
||||
const adapter = new MockAdapter([textResponse('retried')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
vi.spyOn(agent.session, 'append').mockImplementationOnce(() => {
|
||||
throw new Error('append unavailable')
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async () => ({ kind: 'block', reason: 'policy' }))
|
||||
|
||||
agent.followup({ content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' } })
|
||||
agent.inject({
|
||||
content: [{ type: 'text', text: 'retained context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(events(agent)).toEqual([])
|
||||
expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable'))
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.retry()
|
||||
await idle
|
||||
|
||||
expect(events(agent).some(event => event.type === 'user/message'
|
||||
&& JSON.stringify(event.data.content).includes('retained context'))).toBe(true)
|
||||
})
|
||||
|
||||
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ran once')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -293,8 +293,8 @@ describe('agent loop', () => {
|
||||
expect(flat).toContain('change of plans')
|
||||
})
|
||||
|
||||
it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
it('same-tick steering joins the prompt already in admission', async () => {
|
||||
const adapter = new MockAdapter([textResponse('combined')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -303,14 +303,21 @@ describe('agent loop', () => {
|
||||
agent.steer({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } })
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)).toEqual([
|
||||
[{ type: 'text', text: 'first idle steer' }],
|
||||
])
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'steering/message')
|
||||
.map(event => event.data.content)).toEqual([
|
||||
[{ type: 'text', text: 'second idle steer' }],
|
||||
])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const request = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(request).toContain('first idle steer')
|
||||
expect(request).toContain('second idle steer')
|
||||
})
|
||||
|
||||
it('keeps steering staged after a failed step until retry', async () => {
|
||||
|
||||
Reference in New Issue
Block a user