fix(agent-loop): park empty turn batches, keep max-tokens sticky, and settle headless at idle
- turn boundary now returns false for an empty admitted batch (claimed input removed before the wake) instead of opening a turn and spending a model call on nothing; the step boundary already had the symmetric guard. - a max-token step stays sticky when steering or injected work continues the turn: a later completed step no longer downgrades the outcome, matching the TurnEndReasonMap contract. - session/queue wire schema accepts the context placement (previously the zod union rejected injected-context snapshots wholesale and the client silently dropped the whole frame); schema tests cover all placements. - headless runs settle at whole-agent idle instead of the first turn/end, honoring the one-shot idle-to-idle contract. - flush JSDoc names the real callers (checkpoint policy, goal-session, teardown, self-flushing consumers); apiproxy zh README loses its stale duplicate history section; ACP note/README record the delivered error rejection and turnless-cancelled behaviors.
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/acp/acp/README.md
|
||||
README.md: 4632c00398d0870d3682d8c6241e26de006ad233
|
||||
README.zh.md: 1bfd16def64f7973ae4e517dfc96e32478b49aa2
|
||||
README.md: a48224fca8aac12e1440e187cfc4b418952d0951
|
||||
README.zh.md: 953b7791a0948620706d86a5c30f6629bdd11ae4
|
||||
|
||||
@@ -24,7 +24,7 @@ Both fields are optional so another agent/request listener may supply the target
|
||||
| `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. |
|
||||
| `authenticate` | No-op because the server advertises no authentication methods. |
|
||||
| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. |
|
||||
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation or disposal reports `cancelled`. |
|
||||
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
|
||||
| `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. |
|
||||
| `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. |
|
||||
| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. |
|
||||
@@ -37,7 +37,7 @@ Committed-message output intentionally trades token-by-token latency for a clean
|
||||
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
|
||||
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit and model-error turn endings therefore do not become prompt-level ACP stop reasons.
|
||||
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消或资源释放时报告 `cancelled`。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限或模型错误而结束的轮次不会成为提示词级 ACP 停止原因。
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
@@ -234,6 +234,10 @@ export class ReactLoopAgent implements Agent {
|
||||
try {
|
||||
decision = await this.preStep('next-turn', { turn: phase.turn + 1, step: 1 })
|
||||
if (decision.kind === 'reject') return false
|
||||
// An empty admitted batch (claimed input removed before the wake, or no
|
||||
// runtime-context change) parks the driver instead of opening a turn and
|
||||
// spending a model call on nothing.
|
||||
if (decision.messages.length === 0) return false
|
||||
signal.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw error
|
||||
@@ -256,7 +260,12 @@ export class ReactLoopAgent implements Agent {
|
||||
for (const message of decision.messages) {
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
turnEnds = await this.step(decision.assembly)
|
||||
// max-tokens is sticky: once any step hits the ceiling, later steps
|
||||
// that complete normally must not downgrade the turn outcome.
|
||||
const stepEnd = await this.step(decision.assembly)
|
||||
// max-tokens stays sticky: a later completed step must not
|
||||
// downgrade the turn outcome.
|
||||
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
|
||||
} finally {
|
||||
this.session.append('step/end', { turn, step })
|
||||
}
|
||||
|
||||
@@ -187,6 +187,20 @@ describe('abort during tool execution ends the turn', () => {
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('parks an empty admitted batch instead of opening a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('must not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'go')
|
||||
// The wake microtask has not run yet: remove the only pending message so
|
||||
// the admission batch is empty.
|
||||
agent.inbox.remove('next-turn', agent.inbox.nextTurn[0]!.id)
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(agent.inbox.nextTurn).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('parks result context finalized after disposal cancellation without opening another turn', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -967,7 +967,9 @@ describe('agent loop', () => {
|
||||
source: { kind: 'plugin', plugin: 'max-tokens-test' },
|
||||
},
|
||||
])
|
||||
expect(reasons).toEqual([{ kind: 'completed' }])
|
||||
// A max-token step is sticky: the later completed step must not
|
||||
// downgrade the turn outcome.
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }])
|
||||
})
|
||||
|
||||
it('a completed step after no max-tokens keeps the turn completed (max-tokens does not leak across turns)', async () => {
|
||||
|
||||
@@ -928,10 +928,11 @@ export class SessionStore extends Service {
|
||||
/**
|
||||
* Dispatch the awaited `session/flush` durability checkpoint for `session`,
|
||||
* with the carrier captured at {@link enter}. THE flush entry point: the
|
||||
* store owns the carrier, so callers (the loop's turn-end checkpoint, idle
|
||||
* injection, teardown drains) must come through here rather than dispatch a
|
||||
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
|
||||
* scoped-dispatch invariant can pin it.
|
||||
* store owns the carrier, so callers (the checkpoint policy's per-request
|
||||
* barrier, goal-session's idle checkpoint, teardown drains, and consumers
|
||||
* that flush themselves before reading storage) must come through here
|
||||
* rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
|
||||
* one spelling, and the scoped-dispatch invariant can pin it.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @returns whether at least one durability listener participated, after every
|
||||
* listener has settled successfully.
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 0b8761f57556d0076f45e2d5ab735acd092cb253
|
||||
README.zh.md: 094ce99900eeb788536cd98b7fc464cbc57fd0b9
|
||||
README.zh.md: 983bad4e3cc3d8c2ed8664d1088b2c7a2f700ef4
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
|
||||
|
||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
@@ -54,7 +54,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
sessionId: sessionIdSchema,
|
||||
items: z.array(z.object({
|
||||
id: messageIdSchema,
|
||||
placement: z.union([z.literal('queued'), z.literal('steering')]),
|
||||
placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]),
|
||||
message: messageSchema,
|
||||
})),
|
||||
}),
|
||||
|
||||
@@ -455,6 +455,17 @@ describe('events frame schemas', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts every queue placement and rejects unknown placements', () => {
|
||||
const item = (placement: string) => ({ type: 'session/queue', sessionId: 's', items: [{
|
||||
id: 'm', placement,
|
||||
message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } },
|
||||
}] })
|
||||
for (const placement of ['queued', 'steering', 'context']) {
|
||||
expect(() => muxFrameSchema.parse(item(placement))).not.toThrow()
|
||||
}
|
||||
expect(() => muxFrameSchema.parse(item('bogus'))).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a queue snapshot with malformed items', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow()
|
||||
|
||||
Reference in New Issue
Block a user