Forge Tool Execution Architecture: Domain Layer and Service Interaction
Forge's tool execution system separates concerns through a three-tier architecture where the domain layer defines contracts, service traits abstract infrastructure, and the executor orchestrates between them.
The antinomyhq/forgecode repository implements a modular tool execution pipeline that bridges LLM-generated tool calls with concrete filesystem, network, and shell operations. By isolating domain definitions from infrastructure implementations, the architecture enables swapping service backends without modifying high-level orchestration logic.
Core Components of the Tool Execution Pipeline
Forge organizes tool execution around three primary components that communicate through well-defined interfaces.
ToolRegistry: The Entry Point and Policy Enforcer
The ToolRegistry in crates/forge_app/src/tool_registry.rs receives raw JSON tool calls from the LLM and acts as the first line of defense for validation and policy enforcement.
When call_inner receives a ToolCallFull, it performs three critical operations before forwarding execution:
- Name validation – confirms the tool exists in the
ToolCatalogenum viaToolCatalog::contains - Permission checks – validates the agent has authorization to invoke the tool
- Modality validation – ensures the current model supports required input types like images
If validation passes, the registry converts the raw JSON into a strongly-typed ToolCatalog variant and delegates to the ToolExecutor. Tasks requiring agent delegation bypass the executor and route directly to AgentExecutor.
// crates/forge_app/src/tool_registry.rs
pub async fn call_inner(
&self,
agent: &Agent,
input: ToolCallFull,
context: &ToolCallContext,
) -> anyhow::Result<ToolOutput> {
Self::validate_tool_call(agent, &input.name)?;
if ToolCatalog::contains(&input.name) {
let tool_input: ToolCatalog = ToolCatalog::try_from(input)?;
// Delegates to ToolExecutor::execute
}
}
ToolCatalog: Domain-Level Contract Definition
Located in crates/forge_domain/src/tools/catalog.rs, the ToolCatalog enum represents the public contract between the LLM and Forge's infrastructure. Each variant encapsulates a strongly-typed input struct:
// crates/forge_domain/src/tools/catalog.rs
pub enum ToolCatalog {
#[serde(alias = "Read")]
Read(FSRead),
#[serde(alias = "Write")]
Write(FSWrite),
FsSearch(FSSearch),
Task(TaskInput),
// ... additional tools
}
The domain layer also provides ToolCallContext, which tracks conversation metrics, todo lists, and per-turn state, ensuring services receive execution context without accessing external state directly.
ToolExecutor: Orchestration and Translation
The ToolExecutor in crates/forge_app/src/tool_executor.rs bridges domain requests and infrastructure implementation. Its call_internal method matches ToolCatalog variants to concrete service calls while handling path normalization, result formatting, and truncation.
Key responsibilities include:
- Path normalization – Resolves relative paths using
normalize_pathwith current working directory context - Service dispatch – Routes to specific service traits like
FsReadServiceorShellService - Result conversion – Transforms service outputs into
ToolOperationvariants for unified handling - Content formatting – Sends user-facing content via
ToolCallContextand dumps large outputs to temporary files
// crates/forge_app/src/tool_executor.rs
async fn call_internal(
&self,
input: ToolCatalog,
context: &ToolCallContext,
) -> anyhow::Result<ToolOperation> {
Ok(match input {
ToolCatalog::Read(input) => {
let normalized_path = self.normalize_path(input.file_path.clone());
let output = self.services
.read(normalized_path,
input.start_line.map(|i| i as u64),
input.end_line.map(|i| i as u64))
.await?;
(input, output).into()
}
// ... additional match arms ...
})
}
Service Traits and Infrastructure Abstraction
All concrete implementations reside behind async traits defined in crates/forge_app/src/services.rs. This abstraction allows the executor to remain agnostic about how operations occur—whether reading local files, executing remote commands, or mocking for tests.
The trait definitions enforce Send + Sync bounds for safe concurrent execution:
// crates/forge_app/src/services.rs
#[async_trait::async_trait]
pub trait FsReadService: Send + Sync {
async fn read(&self, path: String, start_line: Option<u64>, end_line: Option<u64>)
-> anyhow::Result<ReadOutput>;
}
#[async_trait::async_trait]
pub trait ShellService: Send + Sync {
async fn execute(
&self,
command: String,
cwd: PathBuf,
keep_ansi: bool,
silent: bool,
env_vars: Option<Vec<String>>,
description: Option<String>,
) -> anyhow::Result<ShellOutput>;
}
ToolExecutor holds an Arc<S> where S: Services, with the Services super-trait aggregating all individual service traits into a single interface point.
The Complete Execution Flow
The architecture follows a strict pipeline from LLM output to infrastructure call:
- LLM generates a JSON tool call →
ToolCallFull - ToolRegistry validates permissions and converts to
ToolCatalog - ToolExecutor normalizes paths and dispatches to service traits
- Service implementations execute concrete operations
- Results flow back through
ToolOperation→ToolOutput→ LLM context
This separation ensures that adding new tools requires only:
- Defining input structs in
forge_domain - Adding variants to
ToolCatalog - Implementing corresponding service traits
- Extending the executor's match statement
Extending the System with New Tools
To add a custom tool like GitStatus, implement the following five steps:
1. Define the domain input struct with ToolDescription derive:
// crates/forge_domain/src/tools/git_status.rs
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)]
#[tool_description_file = "crates/forge_domain/src/tools/descriptions/git_status.md"]
pub struct GitStatus {
#[serde(default)]
pub path: Option<String>,
}
2. Register in ToolCatalog:
pub enum ToolCatalog {
// ... existing ...
GitStatus(GitStatus),
}
3. Declare the service trait in services.rs:
#[async_trait::async_trait]
pub trait GitService: Send + Sync {
async fn status(&self, path: Option<String>) -> anyhow::Result<String>;
}
4. Implement the trait in your infrastructure crate (e.g., forge_git).
5. Update ToolExecutor::call_internal:
ToolCatalog::GitStatus(input) => {
let normalized = input.path.map(|p| self.normalize_path(p));
let output = self.services.git_status(normalized).await?;
ToolOperation::GitStatus { output }
}
Summary
- Domain isolation:
forge_domaindefinesToolCatalog,ToolCallContext, and input structs, creating a stable contract between LLM and infrastructure. - Service abstraction: Async traits in
services.rsdecouple execution logic from implementation details, enabling test mocks and runtime variations. - Registry enforcement:
ToolRegistryhandles validation, permissions, and timeouts before delegating to the executor. - Executor orchestration:
ToolExecutormanages path normalization, service dispatch, and result formatting without knowing concrete implementation details. - Extensibility: New tools require only domain types, service traits, and match arm extensions—no changes to core orchestration logic.
Frequently Asked Questions
What is the difference between ToolRegistry and ToolExecutor?
ToolRegistry handles the external interface: validating tool names against the ToolCatalog, checking agent permissions, verifying image modality support, and managing timeouts. It decides whether to route to ToolExecutor (for standard tools), AgentExecutor (for Task variants), or MCPExecutor (for macro commands). ToolExecutor focuses on the internal mechanics of converting domain requests into service calls, handling path normalization and result truncation.
How does Forge handle large tool outputs that exceed context limits?
The ToolExecutor detects large outputs during the formatting phase. When content exceeds thresholds, it calls dump_operation to write results to temporary files rather than inline content. The LLM receives a reference to the temp file location instead of the full output, preventing context window overflow while maintaining data accessibility.
Can service implementations be swapped without modifying the executor?
Yes. Because ToolExecutor depends only on abstract traits defined in services.rs (like FsReadService or ShellService), you can substitute implementations by providing different concrete types that satisfy the trait bounds. The executor receives these via Arc<S> dependency injection, making it straightforward to use mock services for testing, remote filesystem implementations for cloud environments, or sandboxed shells for security.
Where does path normalization occur in the architecture?
Path normalization happens exclusively in ToolExecutor::call_internal via the normalize_path helper method. This ensures all relative paths are resolved against the current working directory before reaching service implementations. Services receive absolute paths, eliminating path resolution ambiguities in downstream infrastructure code.
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 →