The Extra Round After the Agent Budget
A DeepTutor scheduler replay checks stage budgets, the single recovery round, forced-finish accounting, and failure paths that must not become silent retries.
With one exploration round and one settlement round configured, a synthetic DeepTutor turn finished after three rounds. Its counters read exploration_rounds=1 and settlement_rounds=2. The settlement count exceeded its configured budget because the scheduler had granted one recovery round.
That is an explicit branch in the scheduler, rather than an accidental loop overrun. It also means that adding the two configured stage budgets is not enough to interpret the final rounds metric. A separate forced-finish path can add a round without increasing either stage counter.
I examined revision 5246fc867678d7f1fda101ee89ff1a1615dbee91, compiled the unchanged TypeScript scheduler, contracts, and scheduler test file using TypeScript 5.9.3, and ran them with Node 24.19.0 on Linux. The custom replay used synthetic host scripts. No provider, real tool, Python bridge, or complete application was executed.
A Scheduler Separated From Its Effects
DeepTutor has a durable turn service, tool and capability registries, session state, and multiple application entry points. The examined @deeptutor/chat-engine package declares version 0.1.0 and Node >=22. That package version identifies the inspected scheduler package, not an installed Python application version.
The relevant architecture is small enough to test directly. run-loop.ts owns counters and control flow. It invokes a host for effects such as call_round, dispatch_tools, and finish. The host returns facts rather than model text or tool arguments:
{
"hasTools": false,
"hasText": true,
"truncated": false,
"reasoningBudgetExhausted": false
}
The inspected Python host adapter retains conversation data and performs provider and tool operations for this scheduler path. The scheduler does not independently verify that an answer contains the required evidence or that a dispatched action succeeded. It makes decisions from the host's reported facts and capability decisions.
This separation has actual engineering value: the control flow can be exercised without model nondeterminism. It also places a clear limit on the evidence from such tests. A passing scheduler script validates transitions, not the host's claims about the outside world.
Two Ways to Reach Three Rounds
The replay configured:
{
"explorationBudget": 1,
"settlementBudget": 1,
"emptyRecoveryBudget": 2
}
The first two scripted rounds both reported tools. At the next boundary, the scheduler invoked prepare_recovery once. The results depended on that operation and the next round's facts:
| Synthetic case | Total rounds | Exploration | Settlement | Tool steps | Last operation |
|---|---|---|---|---|---|
| Recovery unavailable; forced finish reports one extra round | 3 | 1 | 1 | 2 | forced_finish |
| Recovery available; extra round produces an accepted answer | 3 | 1 | 2 | 2 | finish |
| Recovery available; extra round reports an allowed tool batch | 3 | 1 | 2 | 3 | reject_finish |
| Recovery available; extra round reports a rejected tool batch | 3 | 1 | 2 | 2 | reject_finish |
| Recovery unavailable; forced finish reports no extra round | 2 | 1 | 1 | 2 | forced_finish |
The ordinary fallback delegates to forced_finish, then adds the host's extraRounds to the total. It does not classify that addition as exploration or settlement. My synthetic host returned either zero or one; it did not call a model. These totals therefore demonstrate scheduler accounting, not measured provider-request counts.
The recovery branch works differently. It allows one additional call_round in settlement mode, so the normal round accounting increments settlement_rounds again. It does not reopen exploration. If another iteration reaches the exhausted boundary after recovery has been used, the scheduler rejects completion rather than asking for another recovery.
That explains the third row. An allowed recovery tool batch was dispatched once, but dispatch alone did not produce a completed answer. At the next loop boundary the scheduler rejected completion. The extra round is a bounded opportunity to deliver, not an unlimited extension until the model says it is done.
tool_steps is also narrower than its name might suggest: the scheduler increments it once per completed dispatch_tools operation. It is not a count of every individual tool invocation within a batch.
Tool Restrictions Belong to the Host
The scheduler sees recoveryRejected in the round facts and checks it before dispatch. In the replay's rejected-batch case, the third round increased the round counters but caused no third tool dispatch.
In the inspected Python adapter, prepare_recovery() obtains a capability-specific instruction and filters available schemas to the permitted recovery tool names. call_round() flags a response if it requests a name outside that set. The scheduler consumes that rejection flag; it does not inspect names itself.
This distinction matters for testing. Setting recoveryRejected: true in a synthetic host proves that the scheduler stops the batch. It does not prove that the real adapter's schema filtering, name resolution, or tool execution cannot be bypassed. Those paths need their own integration evidence.
A Failure Is Not Automatically Another Chance
The replay also checked failures around this budget logic. A later nonpartial HostFailure invoked forced finish with reason: "error". A first-round failure propagated. A later failure marked as having partial output propagated without forced finish, preserving the scheduler's rule against silently replaying visible output.
The contracts.ts runtime checks rejected an incomplete call_round response and a forced-finish result claiming extraRounds: 2. TypeScript interfaces alone would not enforce either check at runtime. The permitted extra-round accounting values are exactly zero and one.
An abort inside the synthetic dispatch operation raised AbortError before another host operation began. The fixture's dispatch counter had already increased to one. Cancellation prevented continuation; it did not undo an effect already performed by the host. The scheduler checks its signal before and after awaited host calls, but this replay establishes no deadline or preemption guarantee for a host operation that never returns.
Reproduce the Control Flow
The probe validates SHA-256 digests for both source modules and their emitted JavaScript before importing them. The compiled-file digests correspond to TypeScript 5.9.3 with the flags below. It replaces the effectful host with a scripted fixture; it does not modify the scheduler or contract implementation.
For the scoped compilation, I invoked the locally installed TypeScript compiler with these arguments, using absolute paths to the same three input files:
tsc --target ES2022 --module NodeNext --moduleResolution NodeNext \
--skipLibCheck --typeRoots /workspace/cc-portfolio/node_modules/@types \
--outDir dist \
src/core/run-loop.ts src/core/contracts.ts test/run-loop.test.ts
The compilation exited 0. This checks the selected scheduler files and tests, not the whole engine package. The actual cloud replay command was:
node public/blog/extra-round-after-budget/scheduler-probe.mjs \
/tmp/dt-note-nov1 /tmp/dt-note-nov1/packages/chat-engine/dist
It exited 0. Selected output:
ordinary_cap: rounds=3; exploration=1; settlement=1; tool_steps=2; terminal=forced_finish
recovery_answer: rounds=3; exploration=1; settlement=2; tool_steps=2; terminal=finish
recovery_tools: rounds=3; exploration=1; settlement=2; tool_steps=3; terminal=reject_finish
recovery_rejected: rounds=3; exploration=1; settlement=2; tool_steps=2; terminal=reject_finish
partial_error: propagated; no forced_finish
forced_finish_extra_2: rejected by runtime contract
abort_during_dispatch: synthetic_dispatches=1; no later host operation
PASS: 11 synthetic scheduler cases; no provider, real tools, or Python bridge executed
The unchanged upstream scheduler tests also passed: 22 tests, zero failures, exit 0, using node --test --test-isolation=none on the compiled test file. Those tests include empty responses, truncation, pauses, cancellation, capability redirects, and error handling. They use scripted hosts too.
The practical reading of the budget is a state machine with distinct exit paths. A total round count needs its stage counters and terminal operation beside it. Without those, three rounds can describe an accepted recovery answer, a forced response, or a rejected turn that already dispatched another tool batch.