Replies: 3 comments
|
here's a very drafty PR that is relevant: #9574 |
|
Unrolling the loop into a re-entrant state machine is a solid architectural move. It makes operations first-class, which means cost controls can become just another Operation in the ordered list. One thing worth adding to the Operation list: a spend ceiling or model-downgrade operation. If the session hits a token/cost threshold, the agent could automatically switch to a cheaper model, compact more aggressively, or yield to the user. With per-token APIs that matters a lot; with a self-hosted GPU the ceiling is already fixed, so the operation becomes “am I still within my GPU memory/turn budget?” instead of “how many API credits are left?” If you are running Goose against a local vLLM backend on a rented GPU, UltraWork is a flat-rate GPU rental setup that removes the per-token dimension entirely: https://vibecodingagency.com/gpu-cloud/. Same composable agent logic, but the cost is predictable. |
|
Claude Code's harness converges on the same shape: each turn is effectively a pure function over the transcript, with tool dispatch, permission checks, and compaction folded in as steps rather than suspended coroutine state. Making the conversation itself the state is what unlocks resumability and background execution. The tricky part is deciding which operations (compaction, memory writes, hook side effects) belong inside the turn boundary versus between turns. I traced this layer by layer and rebuilt it as runnable Python examples: |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Unrolling the agent loop
This document proposes unrolling the agent loop into a re-entrant state machine that runs one turn at a time, rather than a monolithic streaming coroutine holding all state in local variables. Each turn runs to a well-defined stopping point and returns; the conversation itself is the state.
This has three benefits. Each turn is a plain function call with a structured return value, making the loop easy to test and reason about. It holds no suspended state between interactions, fitting naturally into event-driven server architectures. And it makes the loop composable: the operations governing each turn can be configured statically, swapped dynamically mid-session, or extended by anyone building on top.
Operations
The conversation is the state. At each turn, Operations are checked in order; the first that applies modifies the conversation and produces the next turn. If none apply, the process ends.
To cover the current functionality we need the following Operations:
When the last message is a user message or tool response, call the current provider/model and append the response. Any tool requests in the response are annotated as unapproved.
If any messages contain unapproved tool requests, resolve their approval state: auto-approve in YOLO mode, run the security scanner if active, or yield to the client if user approval is required.
If there are approved tool requests without matching responses, execute them and append the results as success or failure.
If the provider returns a context length error, or the context count exceeds the auto-compact limit, compact the conversation and replace it with the compacted version.
If there are enough old tool call/response pairs, replace them with a summary.
If any tool calls contain an elicitation request without a response, yield to the client.
If the number of turns since the first user message exceeds the limit, yield to the client.
If the last message is an assistant message without a tool request, evaluate the success criteria if defined. On failure, append a user message prodding the agent to try again.
When subagents running in the background have results to report, they append to the conversation and trigger the next turn. This makes background subagent work a first-class part of the state machine rather than a side channel.
Runs user-defined scripts at any point based on conversation state. Unlike other operations, hooks are cross-cutting and may fire alongside rather than instead of other operations so are an exception to the ordered list rule.
Advantages
This approach breaks up the agent loop into separate operations that can be tested individually and in any combination. No more full-blown MockProviders, just swap out the LLM operation, or test tool approval, compaction, or retry logic against a fixed conversation without touching the provider at all.
The resulting API is also cleaner and makes it near-trivial to swap out system components for application-specific implementations: replace the context management strategy, add adversarial prompt injection detection, enforce a cost budget, or implement rate limiting. Each becomes a self-contained operation dropped into the ordered list.
Similarly, this architecture makes goose itself more dynamic. Switching models mid-session, adjusting which tools are available, changing approval policy based on context, or enabling operations conditionally based on recipe configuration all become straightforward modifications to the operation list rather than changes threaded through a monolithic loop.
Finally, because the state machine yields a clean serializable state at every turn, it composes naturally with any eventing or orchestration infrastructure. Each turn is a short async operation, freeing the server between turns. A tool call can register an external condition and return immediately, with the response arriving hours later when the condition fires. Session state can be handed off between processes via a message queue like Kafka, enabling horizontal scaling and long-running background agents without special coordination machinery. Frameworks like Temporal or Restate can take this further, providing managed scheduling, timeouts, and observability by treating each turn as a discrete unit of work.
All reactions