Skip to content

feat(agent): add single-step execution and mid-turn resume - #2

Open
moedash wants to merge 7 commits into
mainfrom
moe/step-and-resume
Open

feat(agent): add single-step execution and mid-turn resume#2
moedash wants to merge 7 commits into
mainfrom
moe/step-and-resume

Conversation

@moedash

@moedash moedash commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What changed?

Step-level: runLoop's inner body moves into runSingleTurn, and agentStep runs exactly one turn without adding a message. Agent.step() and AgentSession.step() expose it. A step is a turn, so it emits no agent_start, and the session still does its post-run handling each step, so retries and compaction keep working.

Starting and recovering a turn without running it: AgentSession.recordPrompt() builds the turn's messages the way prompt() does and records them, with no model call. AgentSession.prepareStep() settles the tool calls a stopped turn left with no result (findDanglingToolCalls() finds them) and drops a trailing assistant message that holds no answer, returning whether the turn still has work.

Resume: AgentSession.resumeInterruptedTurn() is prepareStep() plus a run to the end of the turn, for a caller that wants the whole turn back in one call. It never re-adds the prompt.

Why?

Driving pi from an external durable executor means checkpointing between steps: start a turn, advance it one step, and pick up a half-finished one, without ever running a whole turn. prompt() runs to agent_end, so it could only do the first of those.

After a hard kill during a tool call, the session ends with an assistant tool-call and no result. That payload is still valid, transformMessages handles it. What you can't do is finish that turn: continue() refuses a trailing assistant message, so the only way forward is a new prompt, which changes the conversation.

Two details worth flagging. Only the trailing message counts, because an aborted tool batch leaves older calls unresolved on purpose and a result appended at the tail would attach to the wrong call. And a settled call reports the outcome as unknown, not failed, since a crash after the tool ran but before its result landed means the side effect may already have happened.

This can't be an extension. The guard that blocks the resume is in the core loop.

How did you test it?

  • Unit Tests
  • Staging
  • End to End Tests

npm run check passes. ./test.sh has pre-existing find tool failures that need the fd binary; they fail the same way on main.

End to end: ran pi under a Temporal worker, killed it with kill -9 mid tool call, and the retry finished the turn with the prompt not re-added. That run drove resumeInterruptedTurn(); recordPrompt() and prepareStep() are covered by unit tests so far.

@moedash moedash changed the title Added single-step execution and mid-turn resume. Add single-step execution and mid-turn resume Aug 19, 2026
@moedash moedash changed the title Add single-step execution and mid-turn resume feat(agent): add single-step execution and mid-turn resume Aug 19, 2026
@moedash
moedash marked this pull request as ready for review August 19, 2026 22:24
@moedash
moedash force-pushed the moe/step-and-resume branch 2 times, most recently from 2037d04 to 442ea6a Compare August 19, 2026 22:31
A call left open earlier in the history is normal, and a result appended at
the tail for one of those pairs with the wrong call. Errored and aborted
messages never reach the provider, so their calls need no result either.
A step is a turn, so it no longer opens a run of its own; the extension turn
index kept resetting to zero. The caller needs the outcome to know whether to
step again, instead of rebuilding the loop's termination rules.
A step skipped post-run handling, so a stepped run lost its retry and its
compaction. The settled result no longer claims the tool failed, because the
effect can have landed before the session stopped. The record is written
before memory, so a failed write can't leave the two out of step.
Shorter locals and a hoisted role list read better than wrapped calls, and the formatter keeps them.
Driving a turn a step at a time needed two things step() could not do on its own: put a
prompt in the transcript without running it, and settle what a stopped turn left behind
without running to the end of the turn. resumeInterruptedTurn is now the second one plus
that run, so the two paths cannot drift.
@moedash
moedash force-pushed the moe/step-and-resume branch from 989c52f to 5032328 Compare September 7, 2026 23:39
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