feat(agent): split a step into a model call, its tool calls and a seal - #4
Open
moedash wants to merge 13 commits into
Open
feat(agent): split a step into a model call, its tool calls and a seal#4moedash wants to merge 13 commits into
moedash wants to merge 13 commits into
Conversation
One function ran the model call and the whole tool batch, so nothing could sit between the model asking for a tool and the tool running. The loop composes the three parts now, and drives itself with the same ones a caller outside it drives, so a stepped turn and a turn pi runs itself stay one implementation.
prepareStep held the wording for an unsettled call, so a caller driving the parts of a step had to spell it out again to close a step a tool call did not come back from.
An executor could decide when a turn ran and nothing smaller, so a tool call of a live session had no unit of work of its own to be retried, timed out or approved in.
The seal skipped the post-run pass, so a provider error never retried and a full context never compacted. A replayed model call opened no turn but its seal still ended one. An abort reached only the unit running at the time, and a second model call on an open step answered as if the run had ended.
Escape calls Agent.abort() directly, so a flag on AgentSession never saw it and a stepped turn kept asking the model. The seal also now reads how many attempts already failed off the transcript, because the counter it used lives in a session the worker path rebuilds per activity.
The fallback went into the worker path only, so a retried seal on the live half found the field already consumed and called a turn answered that wanted a provider retry. The retry budget also now says out loud that it is counted off the transcript, with a test that fails if error messages stop being written there.
A seal that already prepared a retry takes the errored message out of memory, so a second attempt of that seal could not see what the first one decided and called the turn answered. A queued message also starts a turn, so it clears the interrupt the way a prompt does.
The seal picked the last assistant message and hoped it was the step's, which on a durable driver is three separate units of work apart. It also takes the retry count from the caller now, because the transcript it derived it from is something a compaction rewrites. abandonStep is for a driver that gives up between the two.
The count is carried across activities now, and a session rebuilt per activity never sees the message event that resets it in a live one, so three failures anywhere in a turn would end the next step that failed. A caller can also close a step without the post-run pass, because a retry or a compaction on the way out of an interrupt is work nobody asked for.
A driver that owns the session file through a lock can only check that lock before it calls in, and the write a model call ends with lands at the end of a stream that runs for minutes. By then another attempt of the same unit of work can hold the lock, and two writers branch this file rather than corrupting it, so the branch nobody reads is half a turn going missing. `setWriteGuard` runs on the one funnel every entry goes through, which is the only place that is always immediately before a write. Synchronous, because the append path is. Off by default.
moedash
force-pushed
the
moe/turn-executor
branch
from
September 7, 2026 23:39
c45555a to
c6190dc
Compare
moedash
force-pushed
the
moe/turn-steps
branch
from
September 7, 2026 23:39
8c8e493 to
3c3807a
Compare
A review measured the upstream footprint and found an adapter no driver calls plus seven symbols that only the loop itself uses. The step tests drive the sink entry point directly, which is what the driver does.
Preparation belongs to the turn after the one that ended, and the loop kept that turn in a local, so a caller stepping from outside never had it: `prompt()` prepared and `step()` did not, on the same agent with the same responses. A review reproduced it. The agent holds the cursor now, and what preparation returns reaches the calls and the seal of that step. A replay neither prepares nor spends it, and a second attempt at one step reuses it rather than compacting twice.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed?
AgentSession.modelCall(),runToolCall(id)andsealStep(results)split one step into three, andturn.stepshands the same three to a registered executor.modelCall()reports the calls the model asked for, recorded and not run.runToolCall()settles one and reports its result without entering it in the transcript.sealStep()records the results in the order the model asked for the calls, and says whether the turn keeps going.runSingleTurnis those three composed, so a turn pi runs itself and a turn driven from outside stay one implementation.mustRunToolCallsInOrderandunknownToolCallOutcomeare exported: a caller that fans the calls out cannot otherwise know a tool declared itself sequential, andprepareStep()held the unknown-outcome wording privately.Stacked on #3, which added
registerTurnExecutor. Two fixes ride along on its branch: two extension-runner test doubles predategetTurnExecutor()and fail as soon as a turn is driven, andcreateProviderinferred the Cloudflare gateway's api map from whichever models the fetched catalog listed, so a catalog that dropped one stopped the build.Why?
A step was one function: stream the assistant message, run its whole tool batch, decide whether to continue. Nothing could sit between the model asking for a tool and the tool running, so a per-tool retry policy, a timeout, an approval or a budget had nowhere to live.
beforeToolCallis no help, because it runs inside the same step.The results go in together on purpose. A caller running calls concurrently settles them out of order, and the session file is a tree whose entries take their parent from the leaf their writer last saw, so two writers appending as they finish branch it. Pi's own parallel path already appends in call order after the batch, so reporting a result and letting the seal record them leaves the transcript the one pi would have written anyway.
replayedis the other half. Without it a caller that lost its record of the step pays for the response twice, and the first response's calls are left for the next resume to report as unknown outcomes when nothing ran them.How did you test it?
npm run checkpasses and both suites are green. New tests cover each part on its own, and two catch drift: a turn split into three parts and a turn the loop runs itself leave the same transcript, checked at the loop level, at the session file, and inturn-executor.test.tsthrough the executor seam.Review went seven rounds and each found something. Everything below has a test that fails without its fix.
The post-run pass never ran on a seal.
_lastAssistantMessagecomes from an event the seal's session does not see, so a provider error never retried and a full context never compacted. Both seal paths share_postSealPassnow. The retry budget goes with it: its counter lives on a session the worker path rebuilds per activity, so it read zero every time and a failing provider would have been asked again until the step ceiling. It is counted off the transcript instead, and_countTrailingFailuressays out loud that keeping error entries is a contract, not an accident. A retry the pass already decided on is carried rather than re-derived, because_prepareRetrytakes the errored message out of memory and a second attempt of that seal cannot see what the first one chose.An abort reached only the unit running at the time, so a stepped turn took another model call after the user stopped it. The flag lives on
Agentrather thanAgentSession, because Escape callsAgent.abort()directly.prompt()andcontinue()'s drain branches clear it, since each starts a turn.Smaller: a replayed model call opened no turn but its seal still ended one; a second
modelCall()on an open step answered "the response ended the run", which had the caller seal a step it never opened; andclearInterruptis called optionally, so an older build of the core package degrades instead of killing every turn.Later rounds moved three things out of the loop and into the caller. The seal is told which step it closes, by the call ids, and refuses a message that asked for a different set, because on a durable driver the model call and the seal are separate units of work minutes apart. It takes the retry count from the caller too, since the transcript it otherwise derives it from is something a compaction rewrites, and it hands back what the step spent so a caller that outlives the session can carry it.
postRun: falsecloses a step without deciding whether to retry or compact, which is work nobody asked for on a turn the user just stopped.abandonStep()is for a driver that gives up between the model call and the seal, which would otherwise leave the session unable to be interrupted, resumed or stepped again.Driven live against
gpt-4o-minithroughtemporalio/pi-temporal. A two-file turn cost a model call, two overlapping tool calls and a seal, then a model call and a seal. I killed the process between a tool starting and its result landing: the call came back as an unknown outcome, and the tool did not run a second time.Since this description was written
Every session write can be guarded.
SessionManager.setWriteGuardlets a driver refuse an append that is no longer its own. The driver that needs it holds a lock beside the session file, and the write it has to guard is the assistant message at the end of a stream that runs for minutes: an awaited check before the model call is not a check on that write at all. The guard is called on the single append funnel, so an extension writing from a tool callback is covered by the same rule as the driver.