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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user