How Tool Call Context Tracking Works in Forge: The Three-Stage Pipeline Explained
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, 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:
// 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:
// 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 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:
// 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 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:
// 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 walks the message list. For every ContextMessage::Tool, it serializes the output as JSON and emits a FunctionCallOutput item:
// 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) 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:
// 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
ToolCallPayloadstruct inforge_trackerrecords invocation metadata for analytics, while theToolResultstruct inforge_domainstores execution outcomes with a uniquecall_idfor correlation. - The orchestration layer appends results to
ChatContextasContextMessage::Toolentries, preserving message ordering. - When building provider requests, the
FromDomain<ChatContext>implementation converts tool results intoFunctionCallOutputitems (for native tool providers) or rewrites them as user messages viaTransformToolCalls(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, 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. 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, 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →