Skip to content

feat(agent): split a step into a model call, its tool calls and a seal - #4

Open
moedash wants to merge 13 commits into
moe/turn-executorfrom
moe/turn-steps
Open

feat(agent): split a step into a model call, its tool calls and a seal#4
moedash wants to merge 13 commits into
moe/turn-executorfrom
moe/turn-steps

Conversation

@moedash

@moedash moedash commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

What changed?

AgentSession.modelCall(), runToolCall(id) and sealStep(results) split one step into three, and turn.steps hands 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.

runSingleTurn is those three composed, so a turn pi runs itself and a turn driven from outside stay one implementation. mustRunToolCallsInOrder and unknownToolCallOutcome are exported: a caller that fans the calls out cannot otherwise know a tool declared itself sequential, and prepareStep() held the unknown-outcome wording privately.

Stacked on #3, which added registerTurnExecutor. Two fixes ride along on its branch: two extension-runner test doubles predate getTurnExecutor() and fail as soon as a turn is driven, and createProvider inferred 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. beforeToolCall is 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.

replayed is 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?

  • Unit Tests
  • Staging
  • End to End Tests

npm run check passes 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 in turn-executor.test.ts through 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. _lastAssistantMessage comes 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 _postSealPass now. 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 _countTrailingFailures says 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 _prepareRetry takes 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 Agent rather than AgentSession, because Escape calls Agent.abort() directly. prompt() and continue()'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; and clearInterrupt is 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: false closes 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-mini through temporalio/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.setWriteGuard lets 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.

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant