# How Tool Call Context Tracking Works in Forge: The Three-Stage Pipeline Explained

> Learn how Forge tracks tool calls via a three-stage pipeline. Discover how tool results feed back to the model for seamless integration and enhanced AI functionality.

- Repository: [Forge Code/forgecode](https://github.com/antinomyhq/forgecode)
- Tags: deep-dive
- Published: 2026-04-08

---

**Forge tracks tool calls through a three-stage pipeline: UI notification via `ToolCallPayload`, execution resulting in a `ToolResult` with a unique `call_id`, and serialization back to the model as `FunctionCallOutput` or transformed user messages for unsupported providers.**

Tool call context tracking is the mechanism that allows Forge (from the **antinomyhq/forgecode** repository) to maintain state between a model requesting a tool and receiving its output. When the LLM emits a `function_call`, Forge must capture that intent, execute the corresponding tool, and feed the resulting data back into the conversation context so the model can reason over it. This article examines the exact implementation across the UI, orchestration, and provider layers.

## Stage 1: Tool-Call Emission and UI Tracking

When the model decides to invoke a tool, Forge first surfaces this event in the user interface and records it for analytics. The UI handles `ToolCallStart` and `ToolCallEnd` events, creating a structured payload that identifies which tool was invoked and whether it failed.

### Rendering ToolCallStart and ToolCallEnd Events

In [`crates/forge_main/src/ui.rs`](https://github.com/antinomyhq/forgecode/blob/main/crates/forge_main/src/ui.rs), the UI finishes the current writer when it detects a `ToolCallEnd` event, then constructs a `ToolCallPayload` containing the tool name and optional error cause. This payload is handed to the **tracker** for event recording:

```rust
// forge_main/src/ui.rs – handling ToolCallEnd
let payload = if toolcall_result.is_error() {
    let mut r = ToolCallPayload::new(toolcall_result.name.to_string());
    if let Some(cause) = toolcall_result.output.as_str() {
        r = r.with_cause(cause.to_string());
    }
    r
} else {
    ToolCallPayload::new(toolcall_result.name.to_string())
};
tracker::tool_call(payload);               // <⟶ forge_tracker::event::ToolCallPayload>

```

### Recording ToolCallPayload via the Tracker

The payload type is defined in the tracker crate as a simple serializable struct:

```rust
// forge_tracker/src/event.rs
pub struct ToolCallPayload {
    tool_name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    cause: Option<String>,
}

```

This stage ensures that every tool invocation is logged with its metadata before execution begins, establishing the first link in the **tool call context tracking** chain.

## Stage 2: Tool Execution and Context Enrichment

After UI notification, the orchestration layer executes the tool and writes the outcome back into the conversation state. This is where the **context** is enriched with concrete results that the model will see on its next turn.

### The ToolResult Data Structure

The domain crate defines `ToolResult` in [`crates/forge_domain/src/tools/result.rs`](https://github.com/antinomyhq/forgecode/blob/main/crates/forge_domain/src/tools/result.rs) as the canonical representation of a completed tool invocation. It contains the tool name, an optional `ToolCallId` for correlation, and a `ToolOutput` holding the actual data:

```rust
// forge_domain/src/tools/result.rs
pub struct ToolResult {
    name: ToolName,
    call_id: Option<ToolCallId>,
    output: ToolOutput,
}

```

### Appending Results to ChatContext

The orchestrator in [`crates/forge_app/src/orch.rs`](https://github.com/antinomyhq/forgecode/blob/main/crates/forge_app/src/orch.rs) collects results from executed tools and appends them to the `ChatContext` as `ContextMessage::Tool` entries. This preserves the strict ordering of messages—tool call followed by tool result—within the conversation history:

```rust
// Example pattern from tests – adding a tool result
let context = ChatContext::default()
    .add_message(ContextMessage::tool_result(
        forge_app::domain::ToolResult::new("shell")
            .call_id(Some(ToolCallId::new("call_1")))
            .success("ok"),
    ));

```

By storing results within the **ChatContext**, Forge ensures that the complete execution trace is available for the next model request, enabling true multi-turn reasoning with external tools.

## Stage 3: Result Serialization and Model Round-Trip

The final stage converts the stored `ToolResult` into provider-specific message formats. This bridges the internal context representation with the external API requirements of OpenAI, Bedrock, Google, and other providers.

### Converting Tool Results to Provider-Specific Formats

When building the next request, the `FromDomain<ChatContext>` implementation in [`crates/forge_repo/src/provider/openai_responses/request.rs`](https://github.com/antinomyhq/forgecode/blob/main/crates/forge_repo/src/provider/openai_responses/request.rs) walks the message list. For every `ContextMessage::Tool`, it serializes the output as JSON and emits a `FunctionCallOutput` item:

```rust
// forge_repo/src/provider/openai_responses/request.rs – handling ContextMessage::Tool
let call_id = result
    .call_id
    .as_ref()
    .map(|id| id.as_str().to_string())
    .ok_or_else(|| anyhow::anyhow!("Tool result is missing call_id; cannot be sent to Responses API"))?;

let output_json = serde_json::to_string(&result.output)
    .with_context(|| "Failed to serialize tool output as JSON")?;

items.push(oai::InputItem::Item(oai::Item::FunctionCallOutput(
    oai::FunctionCallOutputItemParam {
        call_id,
        output: oai::FunctionCallOutput::Text(output_json),
        id: None,
        status: None,
    },
)));

```

The `call_id` field is critical here—it must match the ID from the original tool call so the provider can correlate the result with the request.

### Handling Non-Tool Providers with TransformToolCalls

Not all models support native function-calling protocols. For these providers, Forge runs the **`TransformToolCalls`** transformer (defined in [`crates/forge_domain/src/transformer/transform_tool_calls.rs`](https://github.com/antinomyhq/forgecode/blob/main/crates/forge_domain/src/transformer/transform_tool_calls.rs)) before sending the request. This transformer rewrites `ContextMessage::Tool` entries into ordinary **user** messages, preserving the output content as plain text, images, or AI-generated blocks:

```rust
// forge_domain/src/transformer/transform_tool_calls.rs – converting ToolResult
ContextMessage::Tool(tool_result) => {
    // Convert tool results to user messages
    for output_value in tool_result.output.values.clone() {
        match output_value {
            crate::ToolValue::Text(text) => {
                new_messages.push(ContextMessage::user(text, self.model.clone()).into());
            }
            crate::ToolValue::Image(image) => {
                new_messages.push(ContextMessage::Image(image).into());
            }
            crate::ToolValue::AI { value, .. } => {
                new_messages.push(ContextMessage::user(value, self.model.clone()).into())
            }
            _ => {}
        }
    }
}

```

This fallback mechanism ensures that **tool results are fed back to the model** regardless of the provider's native capabilities, maintaining conversation continuity even with basic chat models.

## Summary

- **Tool call context tracking** in Forge relies on a strict three-stage pipeline: UI event logging, context enrichment via `ToolResult`, and provider-specific serialization.
- The **`ToolCallPayload`** struct in `forge_tracker` records invocation metadata for analytics, while the **`ToolResult`** struct in `forge_domain` stores execution outcomes with a unique **`call_id`** for correlation.
- The orchestration layer appends results to **`ChatContext`** as `ContextMessage::Tool` entries, preserving message ordering.
- When building provider requests, the **`FromDomain<ChatContext>`** implementation converts tool results into `FunctionCallOutput` items (for native tool providers) or rewrites them as user messages via **`TransformToolCalls`** (for unsupported providers).

## Frequently Asked Questions

### How does Forge match tool results to their original calls?

Forge uses the **`call_id`** field within the `ToolResult` struct to maintain correlation. When the model emits a tool call, it generates a unique identifier (wrapped in `ToolCallId`). The orchestrator preserves this ID when creating the `ToolResult`, and the provider-specific serializers in `forge_repo` ensure that the `call_id` in the `FunctionCallOutput` matches the original `function_call` ID. This allows the LLM provider to associate results with the correct tool invocation.

### What happens when a provider doesn't support native tool calls?

For providers lacking tool-call support, Forge applies the **`TransformToolCalls`** transformer before sending the request. Located in [`crates/forge_domain/src/transformer/transform_tool_calls.rs`](https://github.com/antinomyhq/forgecode/blob/main/crates/forge_domain/src/transformer/transform_tool_calls.rs), this component strips tool metadata from assistant messages and converts each `ToolResult` into one or more standard user messages containing the output text, images, or AI-generated content. The model receives the tool output as regular chat content and continues the conversation without native function-calling protocols.

### Where does actual tool execution occur in the codebase?

Tool execution is handled by the **orchestration layer** in [`crates/forge_app/src/orch.rs`](https://github.com/antinomyhq/forgecode/blob/main/crates/forge_app/src/orch.rs). The orchestrator receives the tool call notification from the UI, looks up the appropriate tool implementation, executes it, and then calls helper functions like `collect_results` to gather outputs. Once execution completes, the orchestrator wraps the output in a `ToolResult` and appends it to the `ChatContext`, completing the execution stage of the pipeline.

### What data can a ToolResult contain besides text?

According to the source in [`crates/forge_domain/src/tools/result.rs`](https://github.com/antinomyhq/forgecode/blob/main/crates/forge_domain/src/tools/result.rs), the `ToolOutput` within a `ToolResult` can hold multiple value types including **text**, **images**, and **AI-generated content**. The `TransformToolCalls` implementation specifically handles these variants by mapping `ToolValue::Text` to text user messages, `ToolValue::Image` to image messages, and `ToolValue::AI` to specialized content blocks, ensuring rich multimedia tool outputs are preserved in the conversation context.