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

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

View File

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

View File

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

View File

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