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
+10 -1
View File
@@ -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 })
}