# Forge Tool Execution Architecture: Domain Layer and Service Interaction

> Explore Forge's tool execution architecture. Understand how the domain layer defines contracts and service traits interact with the executor for efficient orchestration.

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

---

**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`](https://github.com/antinomyhq/forgecode/blob/main/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:

1. **Name validation** – confirms the tool exists in the `ToolCatalog` enum via `ToolCatalog::contains`
2. **Permission checks** – validates the agent has authorization to invoke the tool
3. **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`.

```rust
// 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`](https://github.com/antinomyhq/forgecode/blob/main/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:

```rust
// 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`](https://github.com/antinomyhq/forgecode/blob/main/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_path` with current working directory context
- **Service dispatch** – Routes to specific service traits like `FsReadService` or `ShellService`
- **Result conversion** – Transforms service outputs into `ToolOperation` variants for unified handling
- **Content formatting** – Sends user-facing content via `ToolCallContext` and dumps large outputs to temporary files

```rust
// 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`](https://github.com/antinomyhq/forgecode/blob/main/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:

```rust
// 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:

1. **LLM** generates a JSON tool call → `ToolCallFull`
2. **ToolRegistry** validates permissions and converts to `ToolCatalog`
3. **ToolExecutor** normalizes paths and dispatches to service traits
4. **Service implementations** execute concrete operations
5. **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:

```rust
// 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`**:

```rust
pub enum ToolCatalog {
    // ... existing ...
    GitStatus(GitStatus),
}

```

**3. Declare the service trait** in [`services.rs`](https://github.com/antinomyhq/forgecode/blob/main/services.rs):

```rust
#[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`**:

```rust
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_domain` defines `ToolCatalog`, `ToolCallContext`, and input structs, creating a stable contract between LLM and infrastructure.
- **Service abstraction**: Async traits in [`services.rs`](https://github.com/antinomyhq/forgecode/blob/main/services.rs) decouple execution logic from implementation details, enabling test mocks and runtime variations.
- **Registry enforcement**: `ToolRegistry` handles validation, permissions, and timeouts before delegating to the executor.
- **Executor orchestration**: `ToolExecutor` manages 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`](https://github.com/antinomyhq/forgecode/blob/main/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.