End-to-end request lifecycle: how user input becomes a tool call
Stable multi-turn agent runs depend on one replayable path after user input enters: scheduling, persistence, tools, and context governance stay on that path. Agent Forge wires session scheduling, stream-event persistence, tool visibility, and context compaction onto a single main chain. Persistence supports SQLite or Postgres (with tenant isolation). Requests may carry identity headers; mutating paths can enter approval and audit.
Overview: who schedules, who persists, who calls the model
Section titled “Overview: who schedules, who persists, who calls the model”Clients (console, CLI, SDK) do not talk to the model directly. User input becomes an HTTP request on a session, then enters session scheduling. Scheduling decides whether this turn runs a subtask, compacts history, or creates an assistant skeleton and pulls a model stream. Increments from the model and tools write into message parts (Message / Part). The UI’s streaming feel comes from a separate read-only channel that subscribes to those changes.
flowchart LR UI[Client] --> SDK[SDK] SDK --> HTTP[HTTP /session] HTTP --> Prompt[SessionPrompt] Prompt --> Loop[runLoop] Loop --> Proc[Processor] Proc --> LLM[LLM.stream] LLM --> Tools[ToolRegistry] Proc --> Store[(Message / Part)] Store -.-> SSE[SSE /event] SSE --> UI| Role | Responsibility |
|---|---|
| SessionPrompt | Entry: persist user message, drive runLoop, resolve tool set and permissions |
| Processor | Consume model stream events, write Parts incrementally; handle retry, abort, and compact signals |
| LLM adapter | Assemble system / messages / tools, call streamText |
| ToolRegistry + permissions | Decide which tools the model sees; filter again before execute |
| Message / Part store | Cross-turn memory and troubleshooting timeline; default SQLite; with Postgres, same message model plus tenant isolation |
| Bus + SSE | Push Part deltas and session status to subscribed clients |
The runLoop outer loop plus the layers above form the scheduling skeleton on this page. Governance gates sit on the tool-execution boundary; they do not fork a second main path (see the governance series).
Timeline of one request
Section titled “Timeline of one request”The main path is a synchronous prompt: HTTP accepts the request, finishes this turn’s scheduling, then returns the assistant message. Streaming does not push tokens in that POST response body. The client must subscribe to the event stream beforehand (or at the same time).
sequenceDiagram participant C as Client participant H as HTTP participant P as SessionPrompt participant L as runLoop participant R as Processor participant M as LLM participant S as Store participant E as SSE
C->>E: GET /event (long-lived) C->>H: POST /session/:id/message H->>P: prompt P->>S: createUserMessage P->>L: loop L->>S: filterCompacted history L->>R: process R->>M: stream M-->>R: text / tool / reasoning R->>S: incremental Part writes R-->>E: Bus events E-->>C: delta / status L-->>P: continue / stop / compact P-->>C: this-turn assistantStep mapping:
- Client calls the SDK (
prompt/command/shell, and so on). - HTTP routes into the session write path (for example
POST /session/:id/message). createUserMessage: normalize parts, then write the user message.runLoop: read history after compaction filtering; branch on pending subtask / compaction, or enter a model turn.- Processor creates this turn’s assistant skeleton and runs
process. - LLM starts
streamText. - Stream events (text / tool / reasoning / step) land via Processor and reach SSE through the Bus.
processreturnscontinue|stop|compact; the outer loop decides whether to ask the model again.- HTTP returns this turn’s assistant result; deltas usually already reached the client over SSE.
Assistant skeleton: placeholder first, then filled
Section titled “Assistant skeleton: placeholder first, then filled”The “skeleton” in step 5 is an empty assistant message: before the model stream starts, one assistant row lands with parentID (this turn’s user message), role, model, and working directory, while cost and tokens are 0 and finish is absent. It has no Parts yet, so the client sees “the assistant message exists, body not here yet.”
Processor then projects stream events onto that message until the skeleton becomes a complete reply:
flowchart LR L[runLoop] --> Sk[Create assistant skeleton] Sk --> P[processor.create] P --> S[llm.stream] S --> E[Stream events] E --> T[text / reasoning Parts] E --> K[tool Parts] E --> B[step boundary Parts] T --> M[(Same assistant message)] K --> M B --> M M --> F[Close out: finish + tokens + cost]Persistence is event-driven: Processor calls the unified write entry, a sync event triggers the projection layer to write storage, and Processor itself holds no SQL. Its write intents fall into two kinds:
- Full-row updates (new Part, tool state transitions, step boundaries, final finish and usage): stored through sync events and the projection layer.
- Broadcast-only deltas (per-chunk text and reasoning): bus only, for SSE subscribers; the full row is not rewritten on every chunk.
Two things follow: the client does not wait for the turn to end to see the message, and troubleshooting plus cross-turn memory always read the same persisted message instead of a half-finished in-memory object.
Async accept uses prompt_async: HTTP returns acceptance first; the same SessionPrompt path still runs in the background. Progress still goes only over SSE. Details: HTTP streaming and SSE dual channels.
runLoop: when to continue, when to stop
Section titled “runLoop: when to continue, when to stop”runLoop is the ReAct outer loop. The inner loop (which tools run this turn) lives inside the model stream and tool execution. The outer loop only uses finish state and pending tools to decide whether to request another cloud turn.
What each loop owns
Section titled “What each loop owns”One ReAct round contains both “model thinking” and “tool execution,” but they advance in different places:
| Loop | Advances in | Owns | End signal |
|---|---|---|---|
| Inner | One streamText call and its tool execution | Which tool-calls the model issues this round, execution order, how results are written back | finish-step (step complete) |
| Outer | Each runLoop iteration | subtask / compaction branches, whether to ask again, when to finish | Assistant finished with no pending tool-call |
The SDK completes the inner round trip — issue tool-call, execute, hand the tool-result back to the model — without returning to runLoop. The outer loop re-reads history after a model round ends, then decides to continue, compact, or exit:
sequenceDiagram participant O as runLoop (outer) participant I as streamText (inner) participant X as Tool execute O->>I: This round (history + tool table) I->>X: tool-call (may be parallel) X-->>I: tool-result I-->>O: finish-step O->>O: Re-read history for pending tool-call or task O->>I: Ask again when neededExample: the model issues three search tool-calls in parallel. The inner loop executes all three and writes the tool Parts back; when finish-step records the step boundary and usage, the outer loop sees the model still needs to answer from those results and enters another iteration with updated history.
flowchart TD Start[Read filterCompacted history] --> Scan[Backtrack lastUser / lastAssistant / tasks] Scan --> Done{Assistant finished<br/>and no pending tool-call?} Done -->|yes| End[prune then return] Done -->|no| Task{Pending task?} Task -->|subtask| Sub[handleSubtask] --> Start Task -->|compaction| Comp[Run compaction] --> Start Task -->|overflow| Make[Create compaction] --> Start Task -->|none| Remind[Reminders + assistant skeleton] Remind --> Proc[processor.process] Proc --> Out{outcome} Out -->|continue| Start Out -->|stop| EndKey points:
- One exit condition only: the assistant message has finished and no tool-call is pending. Anything else keeps the loop going.
- Pending work outranks a model round: run subtasks first, then compaction or context overflow, and only then build a skeleton and ask the model.
- History uses
filterCompacted: early turns before a compaction boundary are not sent whole to the model, so context does not grow without bound. - Some providers mark
finishwhile leaving unexecuted tool-calls; the outer loop has fallbacks so it does not exit early while tools are still pending. - One session shares one scheduling Runner so two concurrent
runLoops cannot corrupt the same session.
Compaction and summary strategy: runLoop and context compaction.
How tools enter this turn
Section titled “How tools enter this turn”Registration alone does not expose a tool to the model. Entry roughly follows: full builtin + custom set → filter by model / provider / flags → plugins may rewrite definitions → session-side permission gate → execute with arg validation and output truncation.
flowchart LR All[builtin + custom] --> Reg[Filter by model / provider / flags] Reg --> Plug[plugin rewrites definition] Plug --> Perm[resolveTools + permission] Perm --> LLM[This turn’s tool table] LLM --> Exec[execute: validate + truncate]Filter by model / provider / flags
Section titled “Filter by model / provider / flags”This layer answers “which tools make sense for the current model in this runtime,” independent of any single conversation:
- Provider capability: web and code search tools are exposed to the builtin provider by default; other providers need the search flag turned on explicitly.
- File-edit interface the model handles well: newer GPT-family models see
apply_patch, while other models seeeditandwrite, so one change does not get two competing spellings. - Runtime shape and experimental flags: the interactive question tool, plus LSP and plan mode, join the builtin set based on client type or flags.
Session-side permission gate
Section titled “Session-side permission gate”This layer answers “does this conversation allow the model to see it.” Inputs are role defaults, session-level permission, and the per-prompt tool booleans (folded into allow / deny at load):
- A tool matching
pattern: "*"withaction: "deny"is removed from this turn’s tool table entirely. The model cannot see it and will not call it. - A tool set to
falsein per-prompttoolsis removed the same way; anasktool stays visible and gets asked at execute time. - Merge order is role defaults first, session and per-prompt overlays later; later layers only narrow earlier ones.
Example: the role allows bash, and this request passes tools: { bash: false }. The model sees only read/write tools this turn; drop the flag on the next request and bash returns to the table.
Arg validation and output truncation at execute time
Section titled “Arg validation and output truncation at execute time”Tool-call args are validated against the tool’s JSON Schema before execution; results are truncated before being written back:
- One global limit checks lines and bytes together. Over the limit, the head is kept as a preview, the full text lands on disk, and the result carries the path plus a follow-up hint.
- Some tools cap lower: the read tool truncates any single line past 2000 characters, and directory listing or search tools return the first 100 entries by default.
- Truncation only affects the text handed back to the model. The tool really did run, and the model can re-read in slices or switch to a narrower search.
Mutating paths may also attach human gates and audit chaining; when no rule matches, execution follows the diagram above. Visibility and truncation details: Tool system.
Troubleshooting: which segment owns the symptom
Section titled “Troubleshooting: which segment owns the symptom”| Symptom | Check first |
|---|---|
| Provider / model errors | LLM adapter and provider config |
| Model cannot see a tool | Registry filters, session resolveTools, permission rules |
| Tool state stuck or wrong | Processor persistence of tool-call / result and retries |
| Context too long or mysterious truncation | compaction, filterCompacted, tool-output truncation |
| Path out of bounds | Instance directory / worktree boundaries |
| UI has no deltas, only a final blob | Whether SSE was opened before POST; whether the subscription dropped |
Locally, SQLite is enough to reproduce the main path. For multi-tenant or production checks on Postgres, the message model stays the same; isolation applies in storage and request identity. Reliability deployment checks: Deployment verification.
Why the path is layered
Section titled “Why the path is layered”Splitting scheduling, persistence, model adaptation, and tool policy keeps multi-turn traceability and single-point replaceability at once:
- Stream events land as Message / Part; troubleshooting and cross-turn memory share one source of truth.
- Swapping a provider or tool policy does not require rewriting the outer loop.
- Context uses two gates — compaction and truncation — instead of only growing the window.
- The kernel isolates runtime context by platform-root directory (config discovery, tool execution boundary, state cache); user and tenant isolation applies in request context, storage, and the file sandbox.
- Plugin hooks (system, params, headers, tool definitions, message transforms) sit on boundaries, not scattered through business branches.
Cost: troubleshooting must follow the layers; do not assume one HTTP response body carries every delta. Clients must own the SSE subscription lifecycle. Storage dialect, tenant headers, and governance gates need explicit deployment config; default local and multi-tenant production expose different capability surfaces.
Related reading: