ADR-0020: Running agents in threads¶
Status: Accepted Date: 2026-09-28 Deciders: Alex Nodeland
Context¶
Building the agent layer (RFC-0001 phase 3) raised questions the earlier ADRs left open:
- How change notes enter the model's context.
- How the agent's "last seen" point is tracked.
- What a message does when the thread's run is running, idle, or paused on a question.
- How surfaces start, stop and watch runs without each reimplementing it.
Two spikes against pydantic-ai 2.51 also established facts the design relies on:
- Content enqueued in
wrap_runbefore the first request arrives right after the user's prompt. - Raising
ModelRetryfromon_tool_execute_errormakes the model redo the call. - Resuming with
DeferredToolResultsexecutes approved calls. CustomEventreserves the field namedata.- pydantic-ai recognizes a tool's context only from a literal
RunContext[...]annotation.
A new problem appeared as well: if a person replies in the chat while a run is paused on a question, starting a fresh run would leave the model history ending in an unanswered tool call, which model APIs reject.
Decision¶
- Change notes are user-prompt parts wrapped in
<workspace-changes>tags, enqueued before the first request and during the run. They are stored in history like the rest of the conversation. A system-prompt part was rejected because some providers hoist mid-conversation system text out of position. - Last seen is the
seqof the thread's last saved history. Storage records each history chunk with the log's head at the moment it commits, so the next run is briefed on exactly what happened since. wrap_runowns the run's record. It recordsrun_startedand the briefing, runs a log watcher for the run's duration, and ends with exactly one ofrun_paused,run_ended(completed),run_ended(stopped)orrun_ended(failed). The tool-execution hooks record every tool call, including the application's. AnyRejectionbecomes aModelRetrycarrying its message.- A
Runnergives a message one meaning everywhere: - in an idle thread it starts a run
- in a busy thread it steers the running agent, which the watcher delivers
- in a thread whose run is paused, it is the reply: it answers pending questions and declines pending approvals with the message as the reason, then resumes the run
- The
Runneralso provides: answer, which resumes the run once every request is answeredstop, which cancels a run in this processwatch, a run's live frames from itsFanoutChannel- Runs are asyncio tasks in the process that started them. Their thread claim holds across processes, but
stopandwatchonly reach local runs. Cross-process stop and fan-out need a pub/sub channel, deferred beyond v0.1. ArtifactDraft(kind, snapshot, artifact_id)is a ready-madeCustomEventthat application tools emit for drafts. Its field issnapshotbecauseCustomEventreservesdata.- Following is automatic: reading, creating or editing an artifact through the generic tools adds it to the thread's focus.
Options considered¶
What a message does while a run is paused¶
| Option | History stays valid | Natural for people |
|---|---|---|
| Treat it as the reply (chosen) | Yes | Yes: people answer in the chat |
| Start a new run beside the paused one | No: dangling tool calls | Yes |
| Reject the message until the pause is answered | Yes | No |
Where orchestration lives¶
| Option | Duplication across surfaces | Testable without a transport |
|---|---|---|
A Runner in the agent layer (chosen) |
None | Yes |
Each adapter drives agent.run itself |
High | No |
Consequences¶
- Easier: WebSocket, REST and MCP adapters call
runner.send,answer,stopandwatch; none of them implements run logic. - Easier: the whole agent layer is tested with scripted models, with no network and no transport.
- Harder: stopping or watching a run started by another process needs a shared channel (future work, listed in the architecture's open questions).
- A chat reply to a paused run cannot be told apart from an unrelated message. That is acceptable, because the agent sees the text either way.
Action items¶
- Implement
Session,ArtifactWorkspace, the generic tools, live forwarding andRunner(RFC-0001 phase 3). - Use the
Runnerfrom the WebSocket, REST and MCP adapters (phase 5, ADR-0022). - A pub/sub live channel and cross-process stop (after v0.1).