Repository navigation
refactor(agent): separate a prepared agent from its runs - #719
Conversation
|
760f421 to
7205594
Compare
e04038f to
aa84e50
Compare
|
Waiting for #710 to merge so I can rebase afterwards since there will be conflicts and it'll be this branches code that needs updating to satisfy. |
aa84e50 to
a44af75
Compare
84cbbdd to
6221e16
Compare
a44af75 to
a9a6776
Compare
This comment has been minimized.
This comment has been minimized.
a9a6776 to
99e1f78
Compare
99e1f78 to
04074b9
Compare
04074b9 to
fe87379
Compare
`Agent` held config-derived values (model, turn depth, system prompt, context window) alongside state that belongs to one request (scratchpad budget, turn-nudge counters, the skill-invocation recorder) and was rebuilt for every chat request. Split it in two. `PreparedAgent` is the reusable half, built once by `PreparedAgent::prepare`: the provider client, the tools discovered at build time, and the open MCP connections. `Agent` is one run of it, begun with `PreparedAgent::begin_run`, and owns the run's `RunContext`: its request id, a fresh scratchpad budget, fresh turn-nudge counters, and the recorder for the turn's skill invocations, alongside the event channel, tool-call queue and cancel token the context already carries. The recorder is handed to `begin_run` because what it records under, the turn's position in the history and its sequence within the turn, belongs to the turn. An orchestration worker or coordinator begins its run within the orchestration's, through `begin_run_within`: the same id, observer and cancellation, with tool state of its own. Tools and wrappers are built with the prepared agent, so they can no longer hold run state directly. They reach the current run through `BoundRun`, the slot the HITL gate and `request_approval` tool already read: now also the turn-nudge wrapper and `NudgedTool`, the scratchpad wrapper and read tools, `read_artifact`, and the skill tools. The gate and tool take the request id from the run they are bound to. `AgentRuntimeConfig` loses its `turn_nudge` field accordingly. A prepared agent serves one run at a time. Rig spawns its tool server once per agent, so two runs sharing one could not tell their tool calls apart; `begin_run` refuses a second run with `RunInProgress` while the first is alive, which `RunLease` counts: the `Agent` holds one, and so does every stream it produced. A prepared agent is also bound to the request credentials it forwarded. `headers_from_request` values are applied to the MCP servers and the HITL webhook route when the agent is prepared, and the MCP connections open with them, so `ForwardedHeaders` records which inbound headers were forwarded and with what values, and `begin_run` refuses a request that forwards different ones. Without that, preparing once and running per request would run a later request's tool calls under the first request's credentials. Behavior is unchanged: every request still prepares a fresh agent, and orchestration workers and the coordinator go through the same prepare-then-run path, with the coordinator's run recording skill invocations and workers' runs recording none. `RigBuilder::prepare_agent` exposes the reusable half for session-scoped reuse to build on. Fixes: GH-628
fe87379 to
f9bb190
Compare
Shearerbeard
left a comment
There was a problem hiding this comment.
approved with questions for later
| request_id = %self.request_id, | ||
| "no run bound to this gate; its approval reaches no observer" | ||
| ), | ||
| None => tracing::warn!("no run bound to this gate; its approval reaches no observer"), |
There was a problem hiding this comment.
If this logs does it need more context or is that implicit somewhere?
There was a problem hiding this comment.
Not implicit, no. It only fires on the park path (park_pre_call is the only caller of emit), and only if the worker's gate has no run. The gate picks up whatever run is in scope when create_worker builds it, and create_worker always runs inside the factory's with_run, so it shouldn't happen. But if it did, the warn would be close to useless. It used to log the request id from the gate; that id now comes from the run, and this branch is exactly the case where there's no run.
We could do a fast follow in two parts?
- Bind the worker's gate the way a single agent's is bound: set
hitl_gate/hitl_approval_toolon the worker'sPreparedAgent(they'reNonetoday) sobegin_run_withinbinds them.- Then the park path always has a run.
- Approvals behave the same, since the worker's run has the orchestration's id and observer and is cancelled with it.
- For whatever's left (a gate used with no run at all), add the scope (run id, task, session) and agent name to the warn?
This also lines up with #786, where the gate reads presence from the run it's bound to - binding worker gates to their own run is what makes that work for workers, as long as child runs share the session's presence and claim (adding that to #781).
| // No run bound means no budget to check the artifact against; | ||
| // withhold it rather than inline an unbounded read, as the | ||
| // scratchpad read tools do with `ScratchpadToolError::NoRun`. | ||
| let Some(budget) = sp.run.scratchpad_budget() else { | ||
| tracing::warn!( | ||
| "read_artifact: artifact {} withheld — no run is bound", | ||
| filename | ||
| ); | ||
| return format!( | ||
| "[artifact '{filename}' withheld: no run is bound to count it against. \ | ||
| Retry the read_artifact call.]" | ||
| ); | ||
| }; |
There was a problem hiding this comment.
What situation are we in where we don't have data to run scratchpad budget? Just omitted model data in the config?
There was a problem hiding this comment.
Not config - if the context window isn't configured, scratchpad never gets wired up, self.scratchpad is None, and read_artifact takes the first branch and inlines.
This branch is scratchpad on, but no run bound to the tool's slot. Tools are built once when the agent is prepared, before any run exists, so they reach the run's budget through BoundRun instead of holding it. begin_run / begin_run_within bind the run before rig can call any tool, so we can't get here through an Agent. If we ever did, it withholds the artifact rather than inline an unbounded read, same as the scratchpad read tools do with ScratchpadToolError::NoRun. Before the split the tool held the budget directly, so this state couldn't exist.
It isn't impossible by construction, though: the slot starts empty, and rig calls tools on its own task with no context, so a slot is the only way the tool can find its run. Making it impossible means handing each call its run, which needs a change in our rig fork, #787 already says it lives within that rig constraint, so if we want it, it could be its own issue.
The fast follow I'm thinking here is the tool gets its slot from the same place begin_run binds instead of matching it by convention (related to Jake's comment item 11), and the "retry" goes away from the withheld message, since a retry would be withheld too (related to Jake's comment item 7).
|
Went through this after it merged. A couple of these change behavior for callers today. Most of the rest can't happen yet because nothing reuses a prepared agent outside tests, but they will as soon as something does. Changes behavior now
Shows up once a prepared agent is reused
Other bugs
Cleanup
|
Thanks for going through this Jacob, sorry it got merged before I could get to your feedback. Agree with almost all of it. None of it is reachable through the server today (every caller prepares per request, begins one run under the id it streams with, and streams once). But 1 and 2 are real regressions, and 3/4 have to be fixed before anything reuses a prepared agent. This is what I'm thinking for fast follows:
|
Agentheld config-derived values (model, turn depth, system prompt, context window) alongside state that belongs to one request (scratchpad budget, turn-nudge counters, the skill-invocation recorder) and was rebuilt for every chat request.Split it in two.
PreparedAgentis the reusable half, built once byPreparedAgent::prepare: the provider client, the tools discovered at build time, and the open MCP connections.Agentis one run of it, begun withPreparedAgent::begin_run, and owns the run'sRunContext: its request id, a fresh scratchpad budget, fresh turn-nudge counters, and the recorder for the turn's skill invocations, alongside the event channel, tool-call queue and cancel token the context already carries. The recorder is handed tobegin_runbecause what it records under, the turn's position in the history and its sequence within the turn, belongs to the turn. An orchestration worker or coordinator begins its run within the orchestration's, throughbegin_run_within: the same id, observer and cancellation, with tool state of its own.Tools and wrappers are built with the prepared agent, so they can no longer hold run state directly. They reach the current run through
BoundRun, the slot the HITL gate andrequest_approvaltool already read: now also the turn-nudge wrapper andNudgedTool, the scratchpad wrapper and read tools,read_artifact, and the skill tools. The gate and tool take the request id from the run they are bound to.AgentRuntimeConfigloses itsturn_nudgefield accordingly.A prepared agent serves one run at a time. Rig spawns its tool server once per agent, so two runs sharing one could not tell their tool calls apart;
begin_runrefuses a second run withRunInProgresswhile the first is alive, whichRunLeasecounts: theAgentholds one, and so does every stream it produced.A prepared agent is also bound to the request credentials it forwarded.
headers_from_requestvalues are applied to the MCP servers and the HITL webhook route when the agent is prepared, and the MCP connections open with them, soForwardedHeadersrecords which inbound headers were forwarded and with what values, andbegin_runrefuses a request that forwards different ones. Without that, preparing once and running per request would run a later request's tool calls under the first request's credentials.Behavior is unchanged: every request still prepares a fresh agent, and orchestration workers and the coordinator go through the same prepare-then-run path, with the coordinator's run recording skill invocations and workers' runs recording none.
RigBuilder::prepare_agentexposes the reusable half for session-scoped reuse to build on.Fixes: GH-628