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:
_Kerman
2026-08-03 20:36:26 +08:00
parent 81ac6db785
commit ab56e0df0a
15 changed files with 101 additions and 50 deletions

View File

@@ -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

View File

@@ -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 页永不携带该块,未装注册表的组合则两个面都不提供。

View File

@@ -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,
})),
}),

View File

@@ -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()