Skip to content

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
RoleResponsibility
SessionPromptEntry: persist user message, drive runLoop, resolve tool set and permissions
ProcessorConsume model stream events, write Parts incrementally; handle retry, abort, and compact signals
LLM adapterAssemble system / messages / tools, call streamText
ToolRegistry + permissionsDecide which tools the model sees; filter again before execute
Message / Part storeCross-turn memory and troubleshooting timeline; default SQLite; with Postgres, same message model plus tenant isolation
Bus + SSEPush 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).

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 assistant

Step mapping:

  1. Client calls the SDK (prompt / command / shell, and so on).
  2. HTTP routes into the session write path (for example POST /session/:id/message).
  3. createUserMessage: normalize parts, then write the user message.
  4. runLoop: read history after compaction filtering; branch on pending subtask / compaction, or enter a model turn.
  5. Processor creates this turn’s assistant skeleton and runs process.
  6. LLM starts streamText.
  7. Stream events (text / tool / reasoning / step) land via Processor and reach SSE through the Bus.
  8. process returns continue | stop | compact; the outer loop decides whether to ask the model again.
  9. 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 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.

One ReAct round contains both “model thinking” and “tool execution,” but they advance in different places:

LoopAdvances inOwnsEnd signal
InnerOne streamText call and its tool executionWhich tool-calls the model issues this round, execution order, how results are written backfinish-step (step complete)
OuterEach runLoop iterationsubtask / compaction branches, whether to ask again, when to finishAssistant 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 needed

Example: 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| End

Key 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 finish while 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.

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]

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 see edit and write, 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.

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: "*" with action: "deny" is removed from this turn’s tool table entirely. The model cannot see it and will not call it.
  • A tool set to false in per-prompt tools is removed the same way; an ask tool 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”
SymptomCheck first
Provider / model errorsLLM adapter and provider config
Model cannot see a toolRegistry filters, session resolveTools, permission rules
Tool state stuck or wrongProcessor persistence of tool-call / result and retries
Context too long or mysterious truncationcompaction, filterCompacted, tool-output truncation
Path out of boundsInstance directory / worktree boundaries
UI has no deltas, only a final blobWhether 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.

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: